Skip to content

emitter/bundle: record executable-intent explicitly, not via stat() - #158

Open
KNambiarDJsc wants to merge 5 commits into
huggingface:mainfrom
KNambiarDJsc:fix/bundle-tracked-executable-files
Open

KNambiarDJsc wants to merge 5 commits into
huggingface:mainfrom
KNambiarDJsc:fix/bundle-tracked-executable-files

Conversation

@KNambiarDJsc

Copy link
Copy Markdown
Contributor

Summary

Implements the design you approved on #130: "recording executable intent explicitly makes sense for a separate PR. Please keep legacy bundle hashes unchanged, reject invalid or duplicate asset paths, and retain the POSIX chmod checks. Tests should cover old and new bundles on both platforms, including tampered manifests."

Root cause, precisely: bundle_hash() (write side) always got a file's mode from the in-memory TaskFile.executable flag — always correct. inspect_bundle() (verify/resume side) instead re-derived it from item.stat(). That round-trips on POSIX (chmod → stat), but NTFS has no POSIX execute bit for regular files at all, so stat() reports 0o666 unconditionally on Windows — not sometimes wrong, structurally incapable of carrying this information. Every bundle's integrity check was unconditionally False (or raised) there.

What this does:

  • Records every originally-emitted role-scoped path (tracked_files) and its executable subset (executable_files) in task.toml's existing [metadata.repo2env] extension — itself part of the hashed configuration, so still tamper-evident.
  • inspect_bundle() reads executable-intent from that manifest for tracked files instead of stat(). On POSIX it additionally still cross-checks the real mode against it — unchanged strictness, a real chmod tamper is still caught exactly as today.
  • Absence of tracked_files marks a legacy bundle (emitted before this change): _identity() keeps the exact original hash shape for those, so already-published bundle identities never change — verified against a golden hash captured from the pre-change algorithm (test_legacy_bundle_hash_is_unchanged).
  • Rejects invalid or duplicate asset paths: both tracked_files and executable_files are validated through the same relative_asset_path shape check every other asset uses, duplicates are rejected, and executable_files must be a subset of tracked_files.

A real gap I found mid-implementation, not anticipated in the original proposal: the quality loop (quality/loop/artifacts.py) appends files directly to an already-written bundle directory, then re-stamps bundle_hash by calling inspect_bundle() again. My first pass broke this — it treated any file not in the manifest as "must be non-executable," which raised on real executable files added this way. Fixed by tracking the complete originally-emitted file set (not just the executable subset), so anything added later by other tooling falls back to today's exact stat()-only leniency, unaffected either way. Caught this by running the full suite, not just this module's own tests — see test plan.

Test plan

  • tests/test_task_bundle.py — 12 new tests: golden legacy-hash value, legacy bundle still round-trips, tracked/executable manifest recorded correctly, POSIX chmod-tamper still caught (both for tracked files and for a manifest-tamper-only case), Windows-equivalent proof (real sys.platform forced to win32, every file's real mode forced to 0o666, integrity still holds), content-tamper still caught even under simulated Windows, invalid/duplicate executable_files entries rejected, and the quality-loop append-and-restamp pattern still works
  • WSL/Linux, full suite: 2015 passed, 0 failed (unaffected — was already 0 failed)
  • Windows, controlled before/after on the identical commit (same machine, same env, only this diff applied/reverted): 276 failed / 74 errors on main → 180 failed / 18 errors with this change, full suite
  • Real Windows, not just simulated: ran test_task_bundle.py natively — everything passes except test_symlink_rejected, which fails identically on unmodified main (confirmed via git stash) — the separately-tracked symlink-privilege gap from Track native Windows controller and artifact portability #130, unrelated to this change and out of scope here
  • ruff check . / ruff format --check . — clean

Scope note

This PR is the file-modes item only. Symlinks (real product site: execution/hub_asset_alias.py:100, plus several test-fixture cases) remain the one item from #130's "file modes, symlinks, and process cleanup" list still untouched — happy to pick that up next if useful.

KarthikNambiar04 added 2 commits September 22, 2026 11:20
…-isolation fix

huggingface#145 stopped baking the oracle/verifier/instruction into pr_diff's agent
image, but already-published tasks (e.g. AdithyaSK/repo2rlenv-pr-diff, 181
tasks) keep the pre-fix Dockerfile until re-emitted — huggingface#155.

repo2rlenv migrate pr-diff <dir> [--apply] detects the baked-oracle marker
and rewrites environment/Dockerfile + tests/{test.sh,verifier.py,
oracle.patch,instruction.md} via the SAME builder functions fresh generate
calls today, so a migrated task is byte-identical to one emitted now, not
a hand-maintained parallel implementation. instruction.md and
solution/patch.diff (the oracle itself) are never touched, so a migrated
task keeps its original content_hash. repo_url/base_commit are recovered
from the existing Dockerfile's remote set-url line and task.toml's
metadata.repo2env.ref, not re-derived or guessed. Refuses to touch a task
whose instruction.md/solution/patch.diff don't hash to the content_hash
task.toml already claims, rather than silently rewriting an inconsistent
bundle. Defaults to a dry-run report; --apply writes.
bundle_hash() (write side) always got a file's mode from the in-memory
TaskFile.executable flag; inspect_bundle() (verify/resume side) instead
re-derived it from item.stat(). That round-trips on POSIX (chmod ->
stat), but NTFS has no POSIX execute bit for regular files at all, so
stat() reports 0o666 unconditionally on Windows -- not sometimes
wrong, structurally incapable of carrying this information. Every
bundle's integrity check was unconditionally False (or raised) there.

Record every originally-emitted role-scoped path (tracked_files) and
its executable subset (executable_files) in task.toml's existing
[metadata.repo2env] extension -- itself part of the hashed
configuration, so still tamper-evident. inspect_bundle() reads
executable-intent from that manifest for tracked files instead of
stat(); on POSIX it additionally still cross-checks the real mode
against it (unchanged strictness -- a real chmod tamper is still
caught exactly as today). A file present in the directory but not in
tracked_files (the quality loop appends evidence artifacts to an
already-written bundle and re-stamps its hash -- quality/loop/
artifacts.py) falls back to the untouched legacy stat()-only check,
so that pattern keeps working exactly as it always has.

Absence of tracked_files marks a legacy bundle (emitted before this
change): _identity() keeps the exact original hash shape for those,
so already-published bundle identities never change -- verified
against a golden hash captured from the pre-change algorithm.

Verified on both platforms:
- WSL/Linux: 2015 passed, 0 failed (was already 0 failed; unaffected)
- Windows, controlled before/after on the same commit: 276 failed/74
  errors on main -> 180 failed/18 errors with this change (measured
  with the full suite, not just this module's own tests)
- Windows: bundle_hash is deterministic across platforms (the golden
  legacy hash matches whether computed on Linux or Windows); the one
  remaining local test failure (test_symlink_rejected) is the
  separately-tracked symlink-privilege gap from huggingface#130, confirmed to
  fail identically on unmodified main
@adithya-s-k

Copy link
Copy Markdown
Collaborator

Thanks, the explicit executable-intent design makes sense. Two regressions to fix before merging:

  • An unchanged bundle emitted by main still passes inspection but fails write_bundle(..., resume=True) with this change. Please preserve legacy resume as well as inspection hashes.
  • task.toml is read before path checks: a symlink target gets read before rejection, and a FIFO hangs inspection. Validate file types before opening it.

Please add these tests and automate the focused bundle tests on native Windows. This branch also includes #157, so please split it out or rebase after that lands. #130 remains open for the remaining Windows gaps.

KNambiarDJsc and others added 3 commits September 26, 2026 18:46
…repair, symlink refusal

- Recover repo_url from the plain 'git clone' layout of the earliest published
  datasets (e.g. BurntSushi__ripgrep-3166), not only from 'remote set-url'.
- Read and write task files as bytes. A universal-newline read turned the
  instruction's embedded CRLF into LF, so the content_hash check reported a false
  mismatch on a healthy task; writes would also have corrupted the verifier copy
  on Windows. When a mismatch is real, say whether the recorded hash matches the
  instruction baked into the Dockerfile. The hash check is kept.
- Stage the whole repair and swap the Dockerfile in last, so a failed write leaves
  the task unrepaired. A safe Dockerfile only counts as already_safe when every
  grading asset is present; missing ones are rebuilt.
- Refuse symlinked or non-regular paths before any read or write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fore reading task.toml, Windows CI

- write_bundle(resume=True) compares in the identity shape the existing export was
  written in. An unchanged legacy export (no tracked_files) inspects fine but was
  rejected on resume because bundle_hash() now always embeds tracked_files.
- inspect_bundle settles the type of every entry, task.toml included, before it
  opens anything: a symlinked task.toml used to be read before rejection and a
  FIFO blocked inspection forever.
- Run tests/test_task_bundle.py on native Windows in the Windows workflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@KNambiarDJsc

Copy link
Copy Markdown
Contributor Author

Pushed 16726d7 addressing the review.

Legacy resume. Confirmed: an unchanged export written by main inspects fine but write_bundle(resume=True) compared it against bundle_hash(), which now always embeds tracked_files, so it was rejected as a mismatch. Resume now compares in the shape the existing export was written in (legacy export → legacy identity; new export → new identity). The legacy flag is internal (_inspect_bundle); inspect_bundle's return keys are unchanged, since callers spread it into their records (there's a test pinning that). A changed bundle over a legacy export is still rejected.

File types before task.toml. inspect_bundle now settles the type of every entry, task.toml included, using only lstat/stat before it opens anything. A symlinked task.toml (including a dangling one) is rejected without its target being read, and a FIFO is rejected as a special file instead of blocking. The symlink test uses an invalid-TOML target, so on the old order it fails with a parse error instead of the symlink message. The FIFO test runs inspection in a daemon thread with a timeout so a regression fails instead of hanging CI.

Windows CI. windows.yml now runs tests/test_task_bundle.py in the isolated base-wheel venv on the existing py3.12/3.13/3.14 matrix. That venv has only the base dependencies, and the imports on this path (pydantic, tomli-w) are all base. Tests that need a real POSIX chmod skip themselves there.

Split from #157. Splitting would need a force-push of this branch, which I'd rather not do unasked. Instead I merged the updated #157 branch in (so this PR no longer carries the stale copy of it). Once #157 lands the diff here shrinks to bundle.py, its tests and windows.yml. If you'd prefer a rebase onto main after #157 merges, say so and I'll do that.

Local verification, on native Windows: bundle + pr_diff tests 65 passed, 11 skipped (the POSIX-only ones); ruff clean. Since the legacy-resume and FIFO tests can't run on Windows, I ran them once in a throwaway harness that fakes POSIX modes and a FIFO via a patched stat; both passed, and the legacy test fails when the comparison is switched back to always using the new shape. CI's Linux jobs will run them for real. Other failures in my local full run are No module named 'harbor' and Windows-only gaps in unrelated modules (#130).

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants