mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-16 04:28:11 +00:00
* test(release): add fixed-answer native tool accuracy
* fix(release): gate stable artifacts on pinned paired evaluations
Apply review findings #1-4: gate Docker publication, grade in-flight retries, preserve repository context, and execute negative controls in CI.
* fix(eval): verify native context and resolve harness security findings
Inspect Claude's first startup reminder for ordinary repository guidance.
Bound launcher-option matching and escape Markdown backslashes and pipes.
Exclude only the parsed synthetic accuracy corpus from CodeQL; keep the
benchmark harness and evaluator under security analysis.
Validated focused Python and TypeScript regressions plus core typecheck.
* fix(eval): wake the EC2 runner and prove paired evaluator execution
Bootstrap the existing dedicated instance from a protected hosted job, bound
native runner pickup, and stop only instances started by this run. Keep paid
sessions inside the fixed stop window and add a runner-only dispatch.
Require a contained six-cell prepare/session/oracle/report canary in CI,
including a failed repair that remains in the measured denominator.
* fix(ci): reuse existing configuration and disambiguate accuracy cases
Remove the new AWS credential/settings path and release opt-in variable. Bound native runner pickup with the existing GitHub token and retain private EventBridge startup until its existing mechanism can be reused. Reuse current workflow environment names and evolution schedule controls.
Name malformed-observation cases explicitly so the execution audit distinguishes null from missing and actually tests invalid arrays.
* fix(review): isolate release builds and preserve benchmark evidence
* fix(release): gate RC publication on paired quality and manage EC2 lifecycle
* fix(ci): clarify release shell redirects and build script
* fix(eval): remove temporary patch sinks after capture
* fix(bench): retire tool-accuracy allowances repaired on main
Main at 50aa4be3b repaired three #3487 route checks (#3505) and all four
#3499 Python scope checks (#3502, #3504). The ratchet correctly failed the
merged head with stale allowances, so remove them and keep the fixed answers
as release protection: 18/31 pass, 13 recorded gaps remain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release): gate RCs on accuracy only and keep paid runs for stable
Follow the cost split proposed in discussion #3493: fixed-answer tool
accuracy on every RC, paired agent runs before stable releases and on a
weekly schedule against the latest RC.
RC publication no longer prepares a bundle, calls release-evaluation.yml
and waits for a paid run of up to 21 hours on the shared EC2 lock. Every
release attaches this run's accuracy report; stable npm publication still
requires exact-commit paired evidence. release-evaluation.yml drops its
workflow_call path and the now-unused candidate bundle module.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release): address PR #3503 review feedback
- Page through recent release-evaluation runs instead of reading only
the newest 50, so valid stable evidence cannot be pushed off page one.
- Bound each runner-pickup jobs request by the remaining deadline.
- Replace non-finite floats at any depth in failure receipts so the
incomplete report still serializes with allow_nan=False.
- Clarify that the summary.cap case has 501 names over 1001 sites.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(review): harden candidate build and close review gaps
- Mount the candidate checkout's .git read-only inside the build sandbox,
so lifecycle scripts cannot plant git config (e.g. core.fsmonitor) that
host git later runs in that checkout during actions/checkout cleanup.
The real-Bubblewrap canary now also attempts that write.
- Type tool-accuracy observations instead of using explicit any.
- Cover the fail-closed branches reviewers found untested: unsafe or
missing evidence artifacts, mismatched task pins, malformed repo/SHA,
an instance that stops again during bootstrap, and unbalanced GitNexus
guidance markers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(release): describe RC accuracy gate and stable evidence step
- CONTRIBUTING now states that RCs are gated by CI (including the
fixed-answer tool-accuracy check) without a paid run, and that stable
publication needs a passing Release evaluation for the exact commit
within seven days.
- The oracle-control fixture passes tarfile's data filter only where it
exists (3.11.4+), matching the declared Python 3.11 floor.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* refactor(bench): pin tool-accuracy fixture line anchors in one place
run.ts bucketed rename edits and explain findings by line literals that
expectations.ts duplicated. Export FIXTURE_ANCHORS (file, line, pinned text)
from expectations.ts, build the fixed answers and run.ts classifiers from it,
and fail loudly (runner and unit test) when a fixture line no longer holds
the pinned text. Expected answers and observations are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(bench): fail closed when ripgrep is missing from the tool-accuracy run
rename's text-search pass shells out to rg and only logs a degraded warning
when it is absent, so a runner without ripgrep scored a degraded rename
(homonym.ts:1 missing) and still passed. Check rg --version before indexing,
record it in accuracy.json source.externalTools, and install ripgrep in the
ci-tests tool-accuracy job and the docker release-gate (same if: condition).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* refactor(eval): split baseline guidance, share bwrap preamble, drop dead bare option, harden patch cleanup
- Move the baseline (no-GitNexus) guidance scrubbing out of proposer_sandbox.py
into baseline_guidance.py, with its unit tests.
- Expose real_directory, runtime_mount_args and bwrap_base_args publicly so
release_build no longer imports private helpers or re-assembles the shared
bubblewrap preamble; the candidate build command is byte-identical.
- Remove the unused run_claude `bare` parameter and its --bare/--tools branches.
- capture_patch: a failed temporary-directory removal no longer discards the
read patch or replaces an earlier error; it is attached as a note, or warned
on stderr when there was no earlier error.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(ec2-runner): keep stop observing through transient describe failures
- stop() treats a failed or timed-out describe-instances call as unknown
state and keeps observing until the stop deadline; a persistent failure
is still raised at the deadline with the last command error. Unusable
states and identity mismatches still fail at once (new CommandError
subclass separates command failures from semantic ones).
- Readiness CLI has one paid mode: --job-name NAME. --evolve is removed;
skill evolution passes its job name, and tests tie the watchdog command
to the paid job's actual name in both EC2 workflows.
- Jobs-API requests never get a timeout larger than the remaining pickup
budget (previously rounded up to 1s past the deadline).
- Document that the shared concurrency group keeps only one pending run,
so a queued release evaluation can be replaced and must be confirmed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(eval): record verified EC2 lifecycle configuration
The README still described the AWS settings as assumed and unverified.
The OIDC role, environment settings and runner_only lifecycle were
configured and verified live on 2026-10-09.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release-eval): grade both runtimes against one pinned task toolchain
Candidate and stable each staged task dependencies (node_modules,
gitnexus-shared/dist) from their own runtime checkout, so the same v1.6.12
task source was graded with different TypeScript, Vitest and LadybugDB
versions and a per-task solve delta also measured dependency drift.
- Check out and build the pinned task commit once (tasks-base) and point
every task's repo at it for both runtimes; the runtime under test reaches
sessions only through --gitnexus-root. prepare requires --task-repo at the
single pinned task commit (new task-sha subcommand resolves it).
- Retain sandbox_dependency_content_digest per measured row and make
release_gate fail when any task's cells, across candidate and stable, do not
share one recorded dependency digest.
- Move the task-pin comparison into validate_report(task_pins=...) and use
it from release_report check and release_gate.
- Cover the gate CLI with a missing and a corrupt candidate report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(ci): warn that a pending release evaluation can be replaced
GitHub keeps one pending run per concurrency group, so a newer queued
EC2 run cancels a pending release evaluation. Operators must confirm the
startup job ran before relying on the evidence.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(eval): prove hidden oracles against the grading toolchain
Release evaluation now grades every task with dependencies built at the
pinned task commit (TypeScript 5.9.3, Vitest 4.1.11), but the oracle
controls still ran against the current checkout's toolchain. The Ubuntu
containment job now builds the pinned task toolchain and points the
controls at it. All seven controls pass locally against that build.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(ci): pin the duplicated EC2 lifecycle jobs together
Both EC2 workflows carry their own copy of the live-verified start,
check, readiness and stop jobs. A reusable workflow would change that
verified job structure, so instead a test requires the copies to stay
identical apart from the job display name and the paid-job dependency.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release-eval): accept stable evidence only from main's current evaluator
Stable publish validated downloaded agent evidence against the release
commit's eval/ tree and never tied a report to the run that produced it.
Evidence from an older evaluator stayed acceptable for seven days after
graders changed, while task changes on main made every fresh evaluation
mismatch the tag. The first invalid artifact also hid older valid runs.
The composite action now extracts eval/ from main's head and runs the
validator there with locked base dependencies only (no dev extras or
project build). A report counts only when its harness_sha is the head_sha
of its trusted main run and the compare API shows no change under eval/,
the release-evaluation workflow or the pinned agent CLI between that
harness and the evaluator; a missing, diverged or 300-file comparison
fails closed. Runs are tried newest first, rejected runs are skipped
with a bounded reason list, every gh call has a timeout, and task pins
use validate_report's task_pins check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(eval): break the sandbox import cycle and resolve code-scanning alerts
- baseline_guidance is now pure text handling with its own GuidanceError;
the baseline mount builder lives in proposer_sandbox again and converts
that error to SandboxError, so imports run one way only.
- task_assets uses the public real_directory name; the unused private
alias is gone.
- The oracle-control fixture always extracts with tarfile's data filter
and skips on Pythons that lack it, instead of extracting unfiltered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release-eval): run candidate lifecycle scripts offline; reject smuggled report fields
- build_candidate now downloads locked dependencies with --ignore-scripts,
then runs every lifecycle script (dependency installs, prepare, build)
in a fresh network namespace, so candidate code cannot reach host-local
services. Verified locally that the current head and the pinned task
commit build this way; the real-Bubblewrap canary now asserts only
loopback is visible to a lifecycle script.
- validate_report requires the evidence document to equal its
field-whitelisted rebuild, so per-run or top-level fields outside the
published schema are rejected before the publisher copies the file.
- The sandbox test suite also covers a no-GitNexus sandbox refusing
unbalanced guidance markers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(eval): describe the offline candidate lifecycle
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(release-eval): pin downloads to the npm registry; require dependency binding
Address review feedback on PR #3503:
- The candidate's online install phase now refuses lockfile entries that
resolve outside https://registry.npmjs.org/ (other than in-checkout
workspace links) and refuses shipped .npmrc files, so a candidate
cannot steer npm at loopback, private or metadata endpoints.
- Every measured row must carry a 64-hex task-dependency digest, so a
single report cannot be accepted with its grading toolchain unbound.
- Oversized JSON integers mark a measurement untrustworthy instead of
crashing report generation.
- Baseline guidance stripping recognises headings with up to three
leading spaces (CommonMark).
- Remove a stale comment about the old private helper name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(eval): parse CommonMark ATX headings; compare evidence by exact JSON
- Baseline guidance stripping now parses headings as CommonMark ATX
headings (0-3 leading spaces, optional closing # run, tabs), so a
GitNexus section heading like '## GitNexus rules ##' is removed too.
- Evidence validation compares the serialized rebuild with the report,
so a retyped value (true for 1, 2.0 for 2) no longer passes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(eval): drop continuations of every CommonMark list item in baseline guidance
A removed GitNexus instruction left its indented continuation behind when
the item used '+' or an ordered marker (1. / 1)). The scrubber now
recognises every CommonMark list-item marker.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(eval): isolate candidate MCP from agent task writes (#3503)
Run MCP in a nested Bubblewrap boundary with read-only task, graph,
registry and runtime mounts, private state and isolated processes/network.
Keep task writes on the agent's built-in tools and remove mutating MCP grants.
Add startup/tool mutation canaries and preserve explicit unsafe diagnostics.
Validation: focused pytest 138 passed, 16 skipped. Full locked evaluator
996 passed, 29 skipped; real containment canaries require Ubuntu CI.
Note: eight pre-existing comparator-reuse failures reproduce on the
unchanged head due to missing os.supports_dir_fd support. One process-control
timeout failed in the full run and passed alone and on the unchanged head.
* fix(eval): close baseline mount overlap and test symlink evidence (#3503)
Reject supplied no-MCP mounts that cover forbidden GitNexus paths, including ancestor mounts and lexical aliases. Keep ordinary dependency mounts available.
Exercise evidence symlink rejection with a real valid-report target and retain dangling-link coverage.
Validation: 1026 evaluator tests passed, 29 skipped; targeted mount tests 36 passed and evidence tests 34 passed. Removing the symlink guard in memory makes the repaired regression fail. Ruff and diff checks passed.
Note: eight pre-existing comparator-reuse failures remain in this environment because os.supports_dir_fd lacks the required os.lstat support.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1132 lines
45 KiB
Python
1132 lines
45 KiB
Python
"""Immutable, copy-on-write task assets for workflow benchmark clones.
|
|
|
|
``sandbox_copy`` inputs can include a several-hundred-megabyte GitNexus
|
|
index. This module captures each declared input set once, freezes that
|
|
snapshot, and then reflinks it into every arm clone. A clone therefore gets
|
|
an independent inode without paying for another full buffered copy or being
|
|
able to mutate the snapshot used by another arm.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import shutil
|
|
import stat
|
|
import tempfile
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
from . import runtime_mounts
|
|
from .proposer_sandbox import (
|
|
DEPENDENCY_MOUNT_BASENAME,
|
|
SANDBOX_WORKSPACE,
|
|
VITE_TEMP_DIR,
|
|
ReadOnlyMount,
|
|
SandboxError,
|
|
_prepare_clone_target,
|
|
real_directory,
|
|
)
|
|
|
|
# The shipped index is roughly 428 MiB. These are containment limits rather
|
|
# than expected-size assertions: they admit normal growth while preventing a
|
|
# task declaration from turning snapshot preparation into an unbounded walk.
|
|
MAX_TASK_ASSET_ENTRIES = 100_000
|
|
MAX_TASK_ASSET_PATH_BYTES = 4_096
|
|
MAX_TASK_ASSET_BYTES = 2 * 1024 * 1024 * 1024
|
|
|
|
# The largest known real sandbox_copy asset in this harness is the shipped
|
|
# index above (~428 MiB estimated, ~290 MiB measured); budget comfortably
|
|
# above that so it can still materialize via buffered copy on a filesystem
|
|
# that cannot reflink (ext4 CI runners, 9p-backed dev mounts), while staying
|
|
# well below MAX_TASK_ASSET_BYTES so a genuinely oversized or malformed
|
|
# declaration still fails closed instead of silently paying for a slow full
|
|
# copy.
|
|
MAX_BUFFERED_FALLBACK_BYTES = 512 * 1024 * 1024
|
|
COPY_CHUNK_BYTES = 1024 * 1024
|
|
|
|
# linux/fs.h: #define FICLONE _IOW(0x94, 9, int)
|
|
FICLONE = 0x40049409
|
|
_REFLINK_UNAVAILABLE = {
|
|
errno.EXDEV,
|
|
errno.EINVAL,
|
|
errno.ENOTTY,
|
|
errno.EOPNOTSUPP,
|
|
errno.ENOSYS,
|
|
}
|
|
|
|
DEPENDENCY_CONTENT_BINDING_FIELD = "sandbox_dependency_content_digest"
|
|
DEPENDENCY_MANIFEST_BINDING_FIELD = "sandbox_dependency_manifest_digest"
|
|
# Review corpus patches are harness-owned. The task repo (`~/GitNexus`) is the
|
|
# subject checkout and often a different worktree or branch, so these paths
|
|
# must be read from the package that defined the benchmark.
|
|
HARNESS_SANDBOX_COPY_PREFIXES = (PurePosixPath("eval/workflow_bench/review_cases"),)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssetManifestEntry:
|
|
path: PurePosixPath
|
|
kind: str
|
|
size: int = 0
|
|
sha256: str = ""
|
|
mode: int = 0
|
|
link_target: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DependencySnapshot:
|
|
"""One declared dependency captured below the immutable snapshot root."""
|
|
|
|
source: str
|
|
target: str
|
|
snapshot_path: PurePosixPath
|
|
kind: str
|
|
entries: tuple[AssetManifestEntry, ...]
|
|
total_bytes: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskAssetSnapshot:
|
|
"""A frozen task-asset tree and its provenance identity."""
|
|
|
|
root: Path
|
|
digest: str
|
|
manifest_digest: str
|
|
repo_identity: Path
|
|
resolved_sha: str
|
|
declarations: tuple[str, ...]
|
|
entries: tuple[AssetManifestEntry, ...]
|
|
dependency_declarations: tuple[tuple[str, str], ...]
|
|
dependencies: tuple[DependencySnapshot, ...]
|
|
dependency_content_digest: str
|
|
dependency_manifest_digest: str
|
|
total_bytes: int
|
|
|
|
@property
|
|
def dependency_binding(self) -> dict[str, str]:
|
|
"""Canonical fields stored in and validated against task bindings."""
|
|
|
|
return {
|
|
DEPENDENCY_CONTENT_BINDING_FIELD: self.dependency_content_digest,
|
|
DEPENDENCY_MANIFEST_BINDING_FIELD: self.dependency_manifest_digest,
|
|
}
|
|
|
|
def validate_dependency_binding(self, binding: Mapping[str, Any]) -> None:
|
|
"""Fail closed unless ``binding`` names this exact dependency snapshot."""
|
|
|
|
_validate_dependency_binding_values(
|
|
binding,
|
|
content_digest=self.dependency_content_digest,
|
|
manifest_digest=self.dependency_manifest_digest,
|
|
)
|
|
|
|
def materialize(self, clone: Path) -> None:
|
|
"""Replace every declared ``sandbox_copy`` root with its exact snapshot tree."""
|
|
|
|
clone = real_directory(clone, label="asset-staging clone")
|
|
snapshot_root = real_directory(self.root / "sandbox-copy", label="task asset snapshot")
|
|
staging = Path(tempfile.mkdtemp(prefix=".wfbench-assets-", dir=clone.parent))
|
|
fallback_bytes = 0
|
|
try:
|
|
for entry in self.entries:
|
|
destination = staging / Path(*entry.path.parts)
|
|
if entry.kind == "directory":
|
|
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
continue
|
|
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
source = snapshot_root / Path(*entry.path.parts)
|
|
fallback_bytes += _materialize_file(
|
|
source,
|
|
destination,
|
|
entry,
|
|
fallback_budget=MAX_BUFFERED_FALLBACK_BYTES - fallback_bytes,
|
|
)
|
|
|
|
roots = tuple(PurePosixPath(declaration) for declaration in self.declarations)
|
|
for relative in roots:
|
|
_preflight_exact_root(clone, relative)
|
|
for relative in roots:
|
|
_publish_exact_root(staging, clone, relative)
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
|
|
def dependency_mounts(self, clone: Path) -> list[ReadOnlyMount]:
|
|
"""Mount only immutable captured dependency roots into an arm clone."""
|
|
|
|
clone = real_directory(clone, label="dependency clone")
|
|
snapshot_root = real_directory(self.root, label="task asset snapshot")
|
|
mounts: list[ReadOnlyMount] = []
|
|
for dependency in self.dependencies:
|
|
source = snapshot_root / Path(*dependency.snapshot_path.parts)
|
|
metadata = source.lstat()
|
|
expected_directory = dependency.kind == "directory"
|
|
if (
|
|
stat.S_ISLNK(metadata.st_mode)
|
|
or (expected_directory and not stat.S_ISDIR(metadata.st_mode))
|
|
or (not expected_directory and not stat.S_ISREG(metadata.st_mode))
|
|
):
|
|
raise SandboxError(f"dependency snapshot changed: {dependency.source}")
|
|
target = PurePosixPath(dependency.target)
|
|
_prepare_clone_target(
|
|
clone,
|
|
target,
|
|
directory=expected_directory,
|
|
label="dependency",
|
|
)
|
|
mounts.append(
|
|
ReadOnlyMount(
|
|
source=source,
|
|
target=f"{SANDBOX_WORKSPACE}/{target.as_posix()}",
|
|
)
|
|
)
|
|
return mounts
|
|
|
|
|
|
class TaskAssetCache:
|
|
"""Own immutable snapshots for one benchmark invocation."""
|
|
|
|
def __init__(self, root: Path):
|
|
self.root = root.expanduser().absolute()
|
|
self.root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
self._by_definition: dict[
|
|
tuple[str, str, tuple[str, ...], tuple[tuple[str, str], ...], str],
|
|
TaskAssetSnapshot,
|
|
] = {}
|
|
self._closed = False
|
|
|
|
def __enter__(self) -> TaskAssetCache:
|
|
return self
|
|
|
|
def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None:
|
|
self.close()
|
|
|
|
def prepare(
|
|
self,
|
|
task: Mapping[str, Any],
|
|
*,
|
|
repo: Path,
|
|
resolved_sha: str,
|
|
expected_dependency_binding: Mapping[str, Any] | None = None,
|
|
) -> TaskAssetSnapshot:
|
|
"""Capture or reuse all copied and mounted task bytes in one snapshot."""
|
|
|
|
if self._closed:
|
|
raise SandboxError("task asset cache is already closed")
|
|
repo_identity = real_directory(repo, label="task asset repository")
|
|
declarations, relative_paths = _sandbox_copy_declarations(task)
|
|
dependency_declarations = _sandbox_dependency_declarations(task)
|
|
dependency_identity = tuple((declaration.source, declaration.target) for declaration in dependency_declarations)
|
|
harness_identity = _harness_sandbox_copy_identity(relative_paths)
|
|
definition = (
|
|
str(repo_identity),
|
|
resolved_sha,
|
|
declarations,
|
|
dependency_identity,
|
|
harness_identity,
|
|
)
|
|
existing = self._by_definition.get(definition)
|
|
if existing is not None:
|
|
if expected_dependency_binding is not None:
|
|
existing.validate_dependency_binding(expected_dependency_binding)
|
|
return existing
|
|
|
|
building = Path(tempfile.mkdtemp(prefix=".building-", dir=self.root))
|
|
try:
|
|
copy_root = building / "sandbox-copy"
|
|
dependency_root = building / "dependencies"
|
|
copy_root.mkdir(mode=0o700)
|
|
dependency_root.mkdir(mode=0o700)
|
|
budget = _SnapshotBudget()
|
|
builder = _SnapshotBuilder(copy_root, budget=budget)
|
|
dependency_snapshots: list[DependencySnapshot] = []
|
|
repo_fd = os.open(
|
|
repo_identity,
|
|
os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
)
|
|
harness_fd: int | None = None
|
|
try:
|
|
for relative in relative_paths:
|
|
if _is_harness_sandbox_copy(relative):
|
|
if harness_fd is None:
|
|
harness_root = _harness_sandbox_copy_root()
|
|
harness_fd = os.open(
|
|
harness_root,
|
|
os.O_RDONLY
|
|
| os.O_DIRECTORY
|
|
| getattr(os, "O_CLOEXEC", 0)
|
|
| getattr(os, "O_NOFOLLOW", 0),
|
|
)
|
|
descriptor = _open_relative(harness_fd, relative)
|
|
else:
|
|
descriptor = _open_relative(repo_fd, relative)
|
|
try:
|
|
builder.copy_descriptor(descriptor, relative)
|
|
finally:
|
|
os.close(descriptor)
|
|
for index, declaration in enumerate(dependency_declarations):
|
|
container_name = f"{index:05d}"
|
|
container = dependency_root / container_name
|
|
container.mkdir(mode=0o700)
|
|
dependency_builder = _SnapshotBuilder(
|
|
container,
|
|
budget=budget,
|
|
allow_symlinks=True,
|
|
preserve_modes=True,
|
|
)
|
|
descriptor = _open_relative(repo_fd, declaration.source_path)
|
|
try:
|
|
dependency_builder.copy_descriptor(descriptor, PurePosixPath("payload"))
|
|
finally:
|
|
os.close(descriptor)
|
|
# vitest cannot start against a read-only node_modules: vite
|
|
# writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs
|
|
# before loading a TypeScript config. bwrap cannot create
|
|
# that mount point inside an already-read-only bind, so the
|
|
# empty directory is captured here -- before the manifest and
|
|
# both dependency digests are computed, so it is part of the
|
|
# snapshot rather than an untracked mutation of it. The
|
|
# sandbox overlays a tmpfs on it; see VITE_TEMP_DIR.
|
|
payload_entry = dependency_builder.entries.get(PurePosixPath("payload"))
|
|
if (
|
|
payload_entry is not None
|
|
and payload_entry.kind == "directory"
|
|
and PurePosixPath(declaration.target).name == DEPENDENCY_MOUNT_BASENAME
|
|
):
|
|
dependency_builder.ensure_directory(PurePosixPath("payload") / VITE_TEMP_DIR)
|
|
dependency_entries = dependency_builder.finished_entries()
|
|
_validate_dependency_symlinks(
|
|
container,
|
|
dependency_entries,
|
|
mount_target=declaration.target_path,
|
|
)
|
|
payload = next(
|
|
(entry for entry in dependency_entries if entry.path == PurePosixPath("payload")),
|
|
None,
|
|
)
|
|
if payload is None:
|
|
raise SandboxError(f"dependency snapshot is empty: {declaration.source}")
|
|
dependency_snapshots.append(
|
|
DependencySnapshot(
|
|
source=declaration.source,
|
|
target=declaration.target,
|
|
snapshot_path=PurePosixPath("dependencies", container_name, "payload"),
|
|
kind=payload.kind,
|
|
entries=dependency_entries,
|
|
total_bytes=dependency_builder.total_bytes,
|
|
)
|
|
)
|
|
finally:
|
|
os.close(repo_fd)
|
|
if harness_fd is not None:
|
|
os.close(harness_fd)
|
|
|
|
entries = builder.finished_entries()
|
|
manifest_digest = _manifest_digest(entries)
|
|
dependencies = tuple(dependency_snapshots)
|
|
dependency_content_digest, dependency_manifest_digest = _dependency_digests(dependencies)
|
|
if expected_dependency_binding is not None:
|
|
_validate_dependency_binding_values(
|
|
expected_dependency_binding,
|
|
content_digest=dependency_content_digest,
|
|
manifest_digest=dependency_manifest_digest,
|
|
)
|
|
digest = _snapshot_digest(
|
|
repo_identity=repo_identity,
|
|
resolved_sha=resolved_sha,
|
|
declarations=declarations,
|
|
manifest_digest=manifest_digest,
|
|
dependency_content_digest=dependency_content_digest,
|
|
dependency_manifest_digest=dependency_manifest_digest,
|
|
harness_identity=harness_identity,
|
|
)
|
|
destination = self.root / digest
|
|
if destination.exists():
|
|
raise SandboxError(f"task asset snapshot key collision: {digest}")
|
|
os.replace(building, destination)
|
|
_freeze_snapshot(destination)
|
|
snapshot = TaskAssetSnapshot(
|
|
root=destination,
|
|
digest=digest,
|
|
manifest_digest=manifest_digest,
|
|
repo_identity=repo_identity,
|
|
resolved_sha=resolved_sha,
|
|
declarations=declarations,
|
|
entries=entries,
|
|
dependency_declarations=dependency_identity,
|
|
dependencies=dependencies,
|
|
dependency_content_digest=dependency_content_digest,
|
|
dependency_manifest_digest=dependency_manifest_digest,
|
|
total_bytes=budget.total_bytes,
|
|
)
|
|
self._by_definition[definition] = snapshot
|
|
return snapshot
|
|
except BaseException:
|
|
if building.exists():
|
|
shutil.rmtree(building, ignore_errors=True)
|
|
raise
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
if not self.root.exists():
|
|
return
|
|
_thaw_tree(self.root)
|
|
shutil.rmtree(self.root)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _DependencyDeclaration:
|
|
source: str
|
|
target: str
|
|
source_path: PurePosixPath
|
|
target_path: PurePosixPath
|
|
|
|
|
|
@dataclass
|
|
class _SnapshotBudget:
|
|
entries: int = 0
|
|
total_bytes: int = 0
|
|
|
|
|
|
class _SnapshotBuilder:
|
|
def __init__(
|
|
self,
|
|
destination: Path,
|
|
*,
|
|
budget: _SnapshotBudget | None = None,
|
|
allow_symlinks: bool = False,
|
|
preserve_modes: bool = False,
|
|
):
|
|
self.destination = destination
|
|
self.entries: dict[PurePosixPath, AssetManifestEntry] = {}
|
|
self.total_bytes = 0
|
|
self.budget = budget if budget is not None else _SnapshotBudget()
|
|
self.allow_symlinks = allow_symlinks
|
|
self.preserve_modes = preserve_modes
|
|
|
|
def copy_descriptor(self, descriptor: int, relative: PurePosixPath) -> None:
|
|
before = os.fstat(descriptor)
|
|
if stat.S_ISDIR(before.st_mode):
|
|
self._record_directory(relative)
|
|
try:
|
|
names = sorted(os.listdir(descriptor))
|
|
except OSError as exc:
|
|
raise SandboxError(f"sandbox_copy directory is unreadable: {relative}: {exc}") from exc
|
|
for name in names:
|
|
child_relative = relative / name
|
|
child_metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
|
if stat.S_ISLNK(child_metadata.st_mode):
|
|
if not self.allow_symlinks:
|
|
raise SandboxError(f"sandbox_copy must not traverse a symlink: {child_relative}")
|
|
self._copy_symlink(descriptor, name, child_relative, child_metadata)
|
|
continue
|
|
child = _open_child(descriptor, name, child_relative)
|
|
try:
|
|
self.copy_descriptor(child, child_relative)
|
|
finally:
|
|
os.close(child)
|
|
after = os.fstat(descriptor)
|
|
if _mutation_identity(before) != _mutation_identity(after):
|
|
raise SandboxError(f"sandbox_copy directory changed while snapshotting: {relative}")
|
|
return
|
|
if not stat.S_ISREG(before.st_mode):
|
|
raise SandboxError(f"sandbox_copy accepts only regular files and directories: {relative}")
|
|
self._copy_file(descriptor, relative, before)
|
|
|
|
def _record_directory(self, relative: PurePosixPath) -> None:
|
|
self._ensure_parents(relative.parent)
|
|
self._record(AssetManifestEntry(path=relative, kind="directory"))
|
|
destination = self.destination / Path(*relative.parts)
|
|
destination.mkdir(mode=0o700, exist_ok=True)
|
|
|
|
def _copy_file(self, descriptor: int, relative: PurePosixPath, before: os.stat_result) -> None:
|
|
self._ensure_parents(relative.parent)
|
|
if self.budget.total_bytes + before.st_size > MAX_TASK_ASSET_BYTES:
|
|
raise SandboxError("sandbox_copy exceeds the total byte limit")
|
|
destination = self.destination / Path(*relative.parts)
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
output = os.open(destination, flags, 0o600)
|
|
digest = hashlib.sha256()
|
|
copied = 0
|
|
try:
|
|
while True:
|
|
chunk = _read_source_chunk(descriptor, COPY_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
copied += len(chunk)
|
|
if self.budget.total_bytes + copied > MAX_TASK_ASSET_BYTES:
|
|
raise SandboxError("sandbox_copy exceeds the total byte limit")
|
|
digest.update(chunk)
|
|
_write_all(output, chunk)
|
|
captured_mode = stat.S_IMODE(before.st_mode) if self.preserve_modes else 0
|
|
frozen_mode = 0o400 | (0o100 if self.preserve_modes and captured_mode & 0o111 else 0)
|
|
os.fchmod(output, frozen_mode)
|
|
finally:
|
|
os.close(output)
|
|
after = os.fstat(descriptor)
|
|
if copied != before.st_size or _mutation_identity(before) != _mutation_identity(after):
|
|
raise SandboxError(f"sandbox_copy file changed while snapshotting: {relative}")
|
|
self.total_bytes += copied
|
|
self.budget.total_bytes += copied
|
|
self._record(
|
|
AssetManifestEntry(
|
|
path=relative,
|
|
kind="file",
|
|
size=copied,
|
|
sha256=digest.hexdigest(),
|
|
mode=captured_mode,
|
|
)
|
|
)
|
|
|
|
def _copy_symlink(
|
|
self,
|
|
parent_descriptor: int,
|
|
name: str,
|
|
relative: PurePosixPath,
|
|
before: os.stat_result,
|
|
) -> None:
|
|
try:
|
|
target = os.readlink(name, dir_fd=parent_descriptor)
|
|
target_bytes = target.encode("utf-8")
|
|
except (OSError, UnicodeEncodeError) as exc:
|
|
raise SandboxError(f"dependency symlink is unreadable or not UTF-8: {relative}") from exc
|
|
if not target or PurePosixPath(target).is_absolute() or "\x00" in target:
|
|
raise SandboxError(f"dependency symlink must be a bounded relative link: {relative}")
|
|
if len(target_bytes) > MAX_TASK_ASSET_PATH_BYTES:
|
|
raise SandboxError(f"dependency symlink target exceeds the path limit: {relative}")
|
|
if self.budget.total_bytes + len(target_bytes) > MAX_TASK_ASSET_BYTES:
|
|
raise SandboxError("sandbox_copy exceeds the total byte limit")
|
|
destination = self.destination / Path(*relative.parts)
|
|
os.symlink(target, destination)
|
|
after = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
if (
|
|
_mutation_identity(before) != _mutation_identity(after)
|
|
or os.readlink(
|
|
name,
|
|
dir_fd=parent_descriptor,
|
|
)
|
|
!= target
|
|
):
|
|
raise SandboxError(f"dependency symlink changed while snapshotting: {relative}")
|
|
self.total_bytes += len(target_bytes)
|
|
self.budget.total_bytes += len(target_bytes)
|
|
self._record(
|
|
AssetManifestEntry(
|
|
path=relative,
|
|
kind="symlink",
|
|
size=len(target_bytes),
|
|
sha256=hashlib.sha256(target_bytes).hexdigest(),
|
|
link_target=target,
|
|
)
|
|
)
|
|
|
|
def _ensure_parents(self, relative: PurePosixPath) -> None:
|
|
current = PurePosixPath()
|
|
for part in relative.parts:
|
|
current /= part
|
|
existing = self.entries.get(current)
|
|
if existing is not None:
|
|
if existing.kind != "directory":
|
|
raise SandboxError(f"sandbox_copy paths collide at {current}")
|
|
continue
|
|
self._record(AssetManifestEntry(path=current, kind="directory"))
|
|
(self.destination / Path(*current.parts)).mkdir(mode=0o700, exist_ok=True)
|
|
|
|
def _record(self, entry: AssetManifestEntry) -> None:
|
|
_validate_manifest_path(entry.path)
|
|
existing = self.entries.get(entry.path)
|
|
if existing is not None:
|
|
if existing != entry:
|
|
raise SandboxError(f"sandbox_copy paths collide at {entry.path}")
|
|
return
|
|
if self.budget.entries >= MAX_TASK_ASSET_ENTRIES:
|
|
raise SandboxError("sandbox_copy exceeds the entry limit")
|
|
self.entries[entry.path] = entry
|
|
self.budget.entries += 1
|
|
|
|
def ensure_directory(self, relative: PurePosixPath) -> None:
|
|
"""Record and create one extra directory inside this snapshot.
|
|
|
|
Used for harness-owned mount points that must exist in the captured
|
|
bytes rather than be created against a read-only bind at runtime.
|
|
"""
|
|
|
|
self._record_directory(relative)
|
|
|
|
def finished_entries(self) -> tuple[AssetManifestEntry, ...]:
|
|
return tuple(sorted(self.entries.values(), key=lambda entry: entry.path.as_posix()))
|
|
|
|
|
|
def _is_harness_sandbox_copy(relative: PurePosixPath) -> bool:
|
|
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
|
|
return False
|
|
return any(relative == prefix or prefix in relative.parents for prefix in HARNESS_SANDBOX_COPY_PREFIXES)
|
|
|
|
|
|
def _harness_sandbox_copy_root() -> Path:
|
|
return real_directory(runtime_mounts.HARNESS_ROOT, label="harness sandbox_copy root")
|
|
|
|
|
|
def _harness_sandbox_copy_identity(relative_paths: tuple[PurePosixPath, ...]) -> str:
|
|
if not any(_is_harness_sandbox_copy(relative) for relative in relative_paths):
|
|
return ""
|
|
return str(_harness_sandbox_copy_root())
|
|
|
|
|
|
def _sandbox_copy_declarations(
|
|
task: Mapping[str, Any],
|
|
) -> tuple[tuple[str, ...], tuple[PurePosixPath, ...]]:
|
|
raw_declarations = task.get("sandbox_copy", [])
|
|
if not isinstance(raw_declarations, list) or not all(isinstance(item, str) and item for item in raw_declarations):
|
|
raise SandboxError("sandbox_copy must be a list of nonblank repository-relative paths")
|
|
declarations = tuple(raw_declarations)
|
|
paths: list[PurePosixPath] = []
|
|
for raw in declarations:
|
|
relative = PurePosixPath(raw)
|
|
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
|
|
raise SandboxError(f"sandbox_copy must be a repository-relative path: {raw!r}")
|
|
_validate_manifest_path(relative)
|
|
paths.append(relative)
|
|
for index, path in enumerate(paths):
|
|
for other in paths[index + 1 :]:
|
|
if path == other or path in other.parents or other in path.parents:
|
|
raise SandboxError(f"sandbox_copy declarations overlap: {path} and {other}")
|
|
return declarations, tuple(paths)
|
|
|
|
|
|
def _sandbox_dependency_declarations(
|
|
task: Mapping[str, Any],
|
|
) -> tuple[_DependencyDeclaration, ...]:
|
|
raw_declarations = task.get("sandbox_dependencies", [])
|
|
if not isinstance(raw_declarations, list):
|
|
raise SandboxError("sandbox_dependencies must be a list")
|
|
declarations: list[_DependencyDeclaration] = []
|
|
for item in raw_declarations:
|
|
if (
|
|
not isinstance(item, Mapping)
|
|
or set(item) != {"source", "target"}
|
|
or not all(isinstance(item[field], str) and item[field] for field in ("source", "target"))
|
|
):
|
|
raise SandboxError("sandbox_dependencies entries require only nonblank source and target")
|
|
source = str(item["source"])
|
|
target = str(item["target"])
|
|
source_path = PurePosixPath(source)
|
|
target_path = PurePosixPath(target)
|
|
if source_path.is_absolute() or ".." in source_path.parts or not source_path.parts:
|
|
raise SandboxError(f"dependency source must stay inside the repository: {source_path}")
|
|
if target_path.is_absolute() or ".." in target_path.parts or not target_path.parts:
|
|
raise SandboxError(f"dependency target must stay inside the clone: {target_path}")
|
|
_validate_manifest_path(source_path)
|
|
_validate_manifest_path(target_path)
|
|
declarations.append(
|
|
_DependencyDeclaration(
|
|
source=source,
|
|
target=target,
|
|
source_path=source_path,
|
|
target_path=target_path,
|
|
)
|
|
)
|
|
for index, declaration in enumerate(declarations):
|
|
for other in declarations[index + 1 :]:
|
|
if (
|
|
declaration.target_path == other.target_path
|
|
or declaration.target_path in other.target_path.parents
|
|
or other.target_path in declaration.target_path.parents
|
|
):
|
|
raise SandboxError(f"sandbox dependency targets overlap: {declaration.target} and {other.target}")
|
|
return tuple(declarations)
|
|
|
|
|
|
def _open_relative(repo_descriptor: int, relative: PurePosixPath) -> int:
|
|
current = os.dup(repo_descriptor)
|
|
try:
|
|
for index, part in enumerate(relative.parts):
|
|
last = index == len(relative.parts) - 1
|
|
child = _open_child(current, part, PurePosixPath(*relative.parts[: index + 1]), require_directory=not last)
|
|
os.close(current)
|
|
current = child
|
|
return current
|
|
except BaseException:
|
|
os.close(current)
|
|
raise
|
|
|
|
|
|
def _open_child(
|
|
parent_descriptor: int,
|
|
name: str,
|
|
relative: PurePosixPath,
|
|
*,
|
|
require_directory: bool = False,
|
|
) -> int:
|
|
try:
|
|
metadata = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
except OSError as exc:
|
|
raise SandboxError(f"sandbox_copy path is unavailable: {relative}: {exc}") from exc
|
|
if stat.S_ISLNK(metadata.st_mode):
|
|
raise SandboxError(f"sandbox_copy must not traverse a symlink: {relative}")
|
|
if require_directory and not stat.S_ISDIR(metadata.st_mode):
|
|
raise SandboxError(f"sandbox_copy parent must be a directory: {relative}")
|
|
if not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode)):
|
|
raise SandboxError(f"sandbox_copy accepts only regular files and directories: {relative}")
|
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
if stat.S_ISDIR(metadata.st_mode):
|
|
flags |= os.O_DIRECTORY
|
|
else:
|
|
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
try:
|
|
descriptor = os.open(name, flags, dir_fd=parent_descriptor)
|
|
except OSError as exc:
|
|
raise SandboxError(f"sandbox_copy path changed or is unreadable: {relative}: {exc}") from exc
|
|
opened = os.fstat(descriptor)
|
|
if not (stat.S_ISDIR(opened.st_mode) or stat.S_ISREG(opened.st_mode)):
|
|
os.close(descriptor)
|
|
raise SandboxError(f"sandbox_copy accepts only regular files and directories: {relative}")
|
|
if (
|
|
opened.st_dev,
|
|
opened.st_ino,
|
|
stat.S_IFMT(opened.st_mode),
|
|
) != (
|
|
metadata.st_dev,
|
|
metadata.st_ino,
|
|
stat.S_IFMT(metadata.st_mode),
|
|
):
|
|
os.close(descriptor)
|
|
raise SandboxError(f"sandbox_copy path changed while opening: {relative}")
|
|
return descriptor
|
|
|
|
|
|
def _validate_dependency_symlinks(
|
|
container: Path,
|
|
entries: tuple[AssetManifestEntry, ...],
|
|
*,
|
|
mount_target: PurePosixPath,
|
|
) -> None:
|
|
snapshot_boundary = (container / "payload").resolve(strict=True)
|
|
manifest_boundary = PurePosixPath("payload")
|
|
sandbox_boundary = PurePosixPath(SANDBOX_WORKSPACE)
|
|
sandbox_mount = sandbox_boundary / mount_target
|
|
for entry in entries:
|
|
if entry.kind != "symlink":
|
|
continue
|
|
target = PurePosixPath(entry.link_target)
|
|
relative_to_payload = entry.path.relative_to(manifest_boundary)
|
|
sandbox_resolved = PurePosixPath(
|
|
posixpath.normpath((sandbox_mount / relative_to_payload.parent / target).as_posix())
|
|
)
|
|
if sandbox_resolved != sandbox_boundary and sandbox_boundary not in sandbox_resolved.parents:
|
|
raise SandboxError(f"dependency symlink escapes the sandbox workspace: {entry.path}")
|
|
manifest_resolved = PurePosixPath(posixpath.normpath((entry.path.parent / target).as_posix()))
|
|
if manifest_resolved != manifest_boundary and manifest_boundary not in manifest_resolved.parents:
|
|
continue
|
|
link = container / Path(*entry.path.parts)
|
|
try:
|
|
resolved = link.resolve(strict=True)
|
|
resolved.relative_to(snapshot_boundary)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
raise SandboxError(f"dependency symlink is dangling or escapes its snapshot: {entry.path}") from exc
|
|
|
|
|
|
def _preflight_exact_root(clone: Path, relative: PurePosixPath) -> None:
|
|
"""Reject symlink/special hazards while permitting replaceable type conflicts."""
|
|
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
current = os.open(clone, flags)
|
|
try:
|
|
for index, part in enumerate(relative.parts):
|
|
try:
|
|
mode = os.stat(part, dir_fd=current, follow_symlinks=False).st_mode
|
|
except FileNotFoundError:
|
|
return
|
|
last = index == len(relative.parts) - 1
|
|
if stat.S_ISLNK(mode):
|
|
role = "target cannot be a symlink" if last else "target has a symlink parent"
|
|
raise SandboxError(f"sandbox_copy {role}: {relative}")
|
|
if last:
|
|
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
|
|
raise SandboxError(f"sandbox_copy target has an unsupported type: {relative}")
|
|
return
|
|
if stat.S_ISREG(mode):
|
|
return
|
|
if not stat.S_ISDIR(mode):
|
|
raise SandboxError(f"sandbox_copy target parent has an unsupported type: {relative}")
|
|
next_descriptor = os.open(part, flags, dir_fd=current)
|
|
os.close(current)
|
|
current = next_descriptor
|
|
finally:
|
|
os.close(current)
|
|
|
|
|
|
def _open_publish_parent(clone: Path, parent: PurePosixPath) -> int:
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
current = os.open(clone, flags)
|
|
try:
|
|
for part in parent.parts:
|
|
try:
|
|
mode = os.stat(part, dir_fd=current, follow_symlinks=False).st_mode
|
|
except FileNotFoundError:
|
|
mode = None
|
|
if mode is not None and stat.S_ISLNK(mode):
|
|
raise SandboxError(f"sandbox_copy target cannot traverse a symlink: {parent}")
|
|
if mode is not None and not stat.S_ISDIR(mode):
|
|
if not stat.S_ISREG(mode):
|
|
raise SandboxError(f"sandbox_copy target parent has an unsupported type: {parent}")
|
|
os.unlink(part, dir_fd=current)
|
|
mode = None
|
|
if mode is None:
|
|
os.mkdir(part, mode=0o700, dir_fd=current)
|
|
next_descriptor = os.open(part, flags, dir_fd=current)
|
|
os.close(current)
|
|
current = next_descriptor
|
|
return current
|
|
except BaseException:
|
|
os.close(current)
|
|
raise
|
|
|
|
|
|
def _open_existing_parent(root: Path, parent: PurePosixPath) -> int:
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
current = os.open(root, flags)
|
|
try:
|
|
for part in parent.parts:
|
|
next_descriptor = os.open(part, flags, dir_fd=current)
|
|
os.close(current)
|
|
current = next_descriptor
|
|
return current
|
|
except BaseException:
|
|
os.close(current)
|
|
raise
|
|
|
|
|
|
def _remove_entry_at(parent: int, name: str, relative: PurePosixPath) -> None:
|
|
try:
|
|
mode = os.stat(name, dir_fd=parent, follow_symlinks=False).st_mode
|
|
except FileNotFoundError:
|
|
return
|
|
if stat.S_ISLNK(mode) or stat.S_ISREG(mode):
|
|
os.unlink(name, dir_fd=parent)
|
|
return
|
|
if not stat.S_ISDIR(mode):
|
|
raise SandboxError(f"sandbox_copy target has an unsupported type: {relative}")
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
directory = os.open(name, flags, dir_fd=parent)
|
|
try:
|
|
for child in os.listdir(directory):
|
|
_remove_entry_at(directory, child, relative / child)
|
|
finally:
|
|
os.close(directory)
|
|
os.rmdir(name, dir_fd=parent)
|
|
|
|
|
|
def _publish_exact_root(staging: Path, clone: Path, relative: PurePosixPath) -> None:
|
|
source_parent = _open_existing_parent(staging, relative.parent)
|
|
destination_parent = _open_publish_parent(clone, relative.parent)
|
|
try:
|
|
_remove_entry_at(destination_parent, relative.name, relative)
|
|
os.rename(
|
|
relative.name,
|
|
relative.name,
|
|
src_dir_fd=source_parent,
|
|
dst_dir_fd=destination_parent,
|
|
)
|
|
finally:
|
|
os.close(destination_parent)
|
|
os.close(source_parent)
|
|
|
|
|
|
def _materialize_file(
|
|
source: Path,
|
|
destination: Path,
|
|
entry: AssetManifestEntry,
|
|
*,
|
|
fallback_budget: int,
|
|
) -> int:
|
|
metadata = source.lstat()
|
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_size != entry.size:
|
|
raise SandboxError(f"task asset snapshot file changed: {entry.path}")
|
|
temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp")
|
|
source_descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0))
|
|
destination_descriptor = os.open(
|
|
temporary,
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
|
|
0o600,
|
|
)
|
|
fallback_bytes = 0
|
|
try:
|
|
opened = os.fstat(source_descriptor)
|
|
if _mutation_identity(opened) != _mutation_identity(metadata):
|
|
raise SandboxError(f"task asset snapshot file changed: {entry.path}")
|
|
if _try_reflink(source_descriptor, destination_descriptor):
|
|
if os.fstat(destination_descriptor).st_size != entry.size:
|
|
raise SandboxError(f"task asset reflink produced an invalid file: {entry.path}")
|
|
else:
|
|
if entry.size > fallback_budget:
|
|
raise SandboxError(
|
|
"task asset filesystem cannot reflink the snapshot and the buffered fallback limit would be exceeded"
|
|
)
|
|
os.ftruncate(destination_descriptor, 0)
|
|
os.lseek(source_descriptor, 0, os.SEEK_SET)
|
|
while True:
|
|
chunk = os.read(source_descriptor, COPY_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
_write_all(destination_descriptor, chunk)
|
|
fallback_bytes += len(chunk)
|
|
if fallback_bytes != entry.size:
|
|
raise SandboxError(f"task asset snapshot file changed while materializing: {entry.path}")
|
|
if _mutation_identity(opened) != _mutation_identity(os.fstat(source_descriptor)):
|
|
raise SandboxError(f"task asset snapshot file changed while materializing: {entry.path}")
|
|
os.fchmod(destination_descriptor, 0o600)
|
|
finally:
|
|
os.close(destination_descriptor)
|
|
os.close(source_descriptor)
|
|
try:
|
|
os.replace(temporary, destination)
|
|
except BaseException:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
return fallback_bytes
|
|
|
|
|
|
def _try_reflink(source_descriptor: int, destination_descriptor: int) -> bool:
|
|
try:
|
|
fcntl.ioctl(destination_descriptor, FICLONE, source_descriptor)
|
|
return True
|
|
except OSError as exc:
|
|
if exc.errno in _REFLINK_UNAVAILABLE:
|
|
return False
|
|
raise
|
|
|
|
|
|
def _read_source_chunk(descriptor: int, size: int) -> bytes:
|
|
return os.read(descriptor, size)
|
|
|
|
|
|
def _write_all(descriptor: int, data: bytes) -> None:
|
|
view = memoryview(data)
|
|
while view:
|
|
written = os.write(descriptor, view)
|
|
if written <= 0:
|
|
raise OSError("short write while copying task assets")
|
|
view = view[written:]
|
|
|
|
|
|
def _mutation_identity(metadata: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
return (
|
|
metadata.st_dev,
|
|
metadata.st_ino,
|
|
metadata.st_mode,
|
|
metadata.st_size,
|
|
metadata.st_mtime_ns,
|
|
metadata.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def _validate_manifest_path(relative: PurePosixPath) -> None:
|
|
try:
|
|
path_bytes = len(relative.as_posix().encode("utf-8"))
|
|
except UnicodeEncodeError as exc:
|
|
raise SandboxError(f"sandbox_copy path is not valid UTF-8: {relative!s}") from exc
|
|
if path_bytes > MAX_TASK_ASSET_PATH_BYTES:
|
|
raise SandboxError("sandbox_copy exceeds the path byte limit")
|
|
|
|
|
|
def _manifest_digest(entries: tuple[AssetManifestEntry, ...]) -> str:
|
|
payload = [
|
|
{
|
|
"kind": entry.kind,
|
|
"link_target": entry.link_target,
|
|
"mode": entry.mode,
|
|
"path": entry.path.as_posix(),
|
|
"sha256": entry.sha256,
|
|
"size": entry.size,
|
|
}
|
|
for entry in entries
|
|
]
|
|
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def _dependency_digests(dependencies: tuple[DependencySnapshot, ...]) -> tuple[str, str]:
|
|
content_payload = [
|
|
{
|
|
"entries": [
|
|
{
|
|
"kind": entry.kind,
|
|
"link_target": entry.link_target,
|
|
"mode": entry.mode,
|
|
"path": entry.path.as_posix(),
|
|
"sha256": entry.sha256,
|
|
"size": entry.size,
|
|
}
|
|
for entry in dependency.entries
|
|
]
|
|
}
|
|
for dependency in dependencies
|
|
]
|
|
content_digest = hashlib.sha256(
|
|
json.dumps(content_payload, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
manifest_payload = {
|
|
"dependencies": [
|
|
{
|
|
"content": content,
|
|
"source": dependency.source,
|
|
"target": dependency.target,
|
|
}
|
|
for dependency, content in zip(dependencies, content_payload, strict=True)
|
|
],
|
|
"schema_version": 1,
|
|
}
|
|
manifest_digest = hashlib.sha256(
|
|
json.dumps(manifest_payload, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
return content_digest, manifest_digest
|
|
|
|
|
|
def _validate_dependency_binding_values(
|
|
binding: Mapping[str, Any],
|
|
*,
|
|
content_digest: str,
|
|
manifest_digest: str,
|
|
) -> None:
|
|
expected = {
|
|
DEPENDENCY_CONTENT_BINDING_FIELD: content_digest,
|
|
DEPENDENCY_MANIFEST_BINDING_FIELD: manifest_digest,
|
|
}
|
|
supplied = {field: binding.get(field) for field in expected}
|
|
if supplied != expected:
|
|
raise SandboxError("sandbox dependency content changed after task binding")
|
|
|
|
|
|
def _snapshot_digest(
|
|
*,
|
|
repo_identity: Path,
|
|
resolved_sha: str,
|
|
declarations: tuple[str, ...],
|
|
manifest_digest: str,
|
|
dependency_content_digest: str,
|
|
dependency_manifest_digest: str,
|
|
harness_identity: str = "",
|
|
) -> str:
|
|
payload = {
|
|
"declarations": declarations,
|
|
"dependency_content_digest": dependency_content_digest,
|
|
"dependency_manifest_digest": dependency_manifest_digest,
|
|
"harness_identity": harness_identity,
|
|
"manifest_digest": manifest_digest,
|
|
"repo_identity": str(repo_identity),
|
|
"resolved_sha": resolved_sha,
|
|
"schema_version": 3,
|
|
}
|
|
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def _freeze_snapshot(root: Path) -> None:
|
|
for current, directories, files in os.walk(root, topdown=False, followlinks=False):
|
|
for name in files:
|
|
path = Path(current) / name
|
|
mode = path.lstat().st_mode
|
|
relative = path.relative_to(root)
|
|
if stat.S_ISLNK(mode):
|
|
if not relative.parts or relative.parts[0] != "dependencies":
|
|
raise SandboxError(f"task asset snapshot contains an unexpected symlink: {path}")
|
|
continue
|
|
if not stat.S_ISREG(mode):
|
|
raise SandboxError(f"task asset snapshot contains a special file: {path}")
|
|
path.chmod(0o400 | (0o100 if stat.S_IMODE(mode) & 0o111 else 0))
|
|
for name in directories:
|
|
path = Path(current) / name
|
|
mode = path.lstat().st_mode
|
|
relative = path.relative_to(root)
|
|
if stat.S_ISLNK(mode):
|
|
if not relative.parts or relative.parts[0] != "dependencies":
|
|
raise SandboxError(f"task asset snapshot contains an unexpected symlink: {path}")
|
|
continue
|
|
if not stat.S_ISDIR(mode):
|
|
raise SandboxError(f"task asset snapshot contains a special directory: {path}")
|
|
path.chmod(0o500)
|
|
Path(current).chmod(0o500)
|
|
|
|
|
|
def _thaw_tree(root: Path) -> None:
|
|
for current, directories, files in os.walk(root, topdown=True, followlinks=False):
|
|
Path(current).chmod(0o700)
|
|
for name in directories:
|
|
path = Path(current) / name
|
|
if not path.is_symlink():
|
|
path.chmod(0o700)
|
|
for name in files:
|
|
path = Path(current) / name
|
|
if not path.is_symlink():
|
|
path.chmod(0o600)
|
|
|
|
|
|
def capture_task_dependency_binding(
|
|
task: Mapping[str, Any],
|
|
*,
|
|
repo: Path,
|
|
resolved_sha: str,
|
|
) -> dict[str, str]:
|
|
"""Capture dependency bytes long enough to produce their canonical binding."""
|
|
|
|
dependency_task = {
|
|
"sandbox_copy": [],
|
|
"sandbox_dependencies": task.get("sandbox_dependencies", []),
|
|
}
|
|
with tempfile.TemporaryDirectory(prefix="wfbench-dependency-binding-") as temporary:
|
|
with TaskAssetCache(Path(temporary) / "cache") as cache:
|
|
snapshot = cache.prepare(dependency_task, repo=repo, resolved_sha=resolved_sha)
|
|
return snapshot.dependency_binding
|
|
|
|
|
|
def _dependency_mounts(
|
|
task: Mapping[str, Any],
|
|
*,
|
|
clone: Path,
|
|
snapshot: TaskAssetSnapshot,
|
|
) -> list[ReadOnlyMount]:
|
|
declarations = tuple(
|
|
(declaration.source, declaration.target) for declaration in _sandbox_dependency_declarations(task)
|
|
)
|
|
if snapshot.dependency_declarations != declarations:
|
|
raise SandboxError("task asset snapshot does not match this dependency declaration")
|
|
return snapshot.dependency_mounts(clone)
|
|
|
|
|
|
def stage_task_assets(
|
|
task: Mapping[str, Any],
|
|
*,
|
|
repo: Path,
|
|
clone: Path,
|
|
snapshot: TaskAssetSnapshot | None = None,
|
|
) -> list[ReadOnlyMount]:
|
|
"""Materialize copied assets and validate read-only dependency mounts.
|
|
|
|
``snapshot`` is supplied by the benchmark runner so every arm reuses one
|
|
capture. The optional path preserves the historic standalone helper API
|
|
for containment tests and external callers.
|
|
"""
|
|
|
|
repo_identity = real_directory(repo, label="task asset repository")
|
|
declarations, _ = _sandbox_copy_declarations(task)
|
|
if snapshot is not None:
|
|
if snapshot.repo_identity != repo_identity or snapshot.declarations != declarations:
|
|
raise SandboxError("task asset snapshot does not match this task declaration")
|
|
snapshot.materialize(clone)
|
|
return _dependency_mounts(task, clone=clone, snapshot=snapshot)
|
|
|
|
if _sandbox_dependency_declarations(task):
|
|
raise SandboxError("sandbox_dependencies require a caller-owned immutable task asset snapshot")
|
|
|
|
with tempfile.TemporaryDirectory(prefix="wfbench-asset-snapshot-") as temporary:
|
|
with TaskAssetCache(Path(temporary) / "cache") as cache:
|
|
ephemeral = cache.prepare(task, repo=repo_identity, resolved_sha="unbound")
|
|
ephemeral.materialize(clone)
|
|
return []
|