diff --git a/.github/workflows/macos-recovery-prerequisites.yml b/.github/workflows/macos-recovery-prerequisites.yml new file mode 100644 index 0000000..08eddbb --- /dev/null +++ b/.github/workflows/macos-recovery-prerequisites.yml @@ -0,0 +1,106 @@ +name: macOS recovery prerequisites + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/macos-recovery-prerequisites.yml' + - 'experimental/127-macos-contract/**' + - 'experimental/127-macos-prerequisites/**' + push: + branches: [main] + paths: + - '.github/workflows/macos-recovery-prerequisites.yml' + - 'experimental/127-macos-contract/**' + - 'experimental/127-macos-prerequisites/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + synthetic-evidence: + name: macOS prerequisite fixtures (${{ matrix.runner }}, Rust ${{ matrix.toolchain }}) + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + runner: [macos-15, macos-15-intel] + toolchain: ['1.90.0', '1.95.0'] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check malformed-input and expectation regressions + env: + RESEARCH_TMP_ROOT: ${{ runner.temp }} + run: | + python3 -B -m unittest discover -s experimental/127-macos-prerequisites/rust-launcher -p 'test_*.py' + python3 -B -m unittest discover -s experimental/127-macos-prerequisites/exec-identity -p 'test_*.py' + - name: Install pinned research toolchain + env: + RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }} + run: | + rustup toolchain install "$RESEARCH_TOOLCHAIN" --profile minimal --component clippy + rustc "+$RESEARCH_TOOLCHAIN" --version --verbose + - name: Build unpublished Rust launcher fixture + working-directory: experimental/127-macos-prerequisites/rust-launcher + env: + RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }} + run: | + cargo "+$RESEARCH_TOOLCHAIN" clippy --locked --offline --all-targets -- -D warnings + cargo "+$RESEARCH_TOOLCHAIN" build --locked --offline + - name: Measure Rust Command descriptor inheritance + run: | + python3 -B experimental/127-macos-prerequisites/rust-launcher/run.py \ + --binary "$GITHUB_WORKSPACE/experimental/127-macos-prerequisites/rust-launcher/target/debug/macos-command-inheritance-lab" \ + --scratch-root "$RUNNER_TEMP" --result "$RUNNER_TEMP/rust-inheritance.json" + - name: Measure same-PID exec identity + if: ${{ !cancelled() && matrix.toolchain == '1.95.0' }} + run: | + python3 -B experimental/127-macos-prerequisites/exec-identity/run.py \ + --tmp-root "$RUNNER_TEMP" --result "$RUNNER_TEMP/exec-identity.json" + - name: Record bounded evidence summary + if: ${{ !cancelled() }} + env: + EVIDENCE_ROOT: ${{ runner.temp }} + RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }} + run: | + python3 -B - <<'PY' + import hashlib, json, os + from pathlib import Path + for name in ('rust-inheritance.json', 'exec-identity.json'): + if name == 'exec-identity.json' and os.environ['RESEARCH_TOOLCHAIN'] != '1.95.0': + continue + p = Path(os.environ['EVIDENCE_ROOT']) / name + if not p.is_file(): + print(json.dumps({'result': name, 'present': False})) + continue + try: + raw = p.read_bytes() + r = json.loads(raw) + except (OSError, ValueError) as error: + print(json.dumps({'result': name, 'parseError': type(error).__name__})) + continue + print(json.dumps({'result': name, 'sha256': hashlib.sha256(raw).hexdigest(), + 'ok': r.get('ok'), 'checks': r.get('checks'), + 'sourceHashes': {k: r[k] for k in ('script_sha256', 'shared_sha256', 'binary_sha256', + 'sourceSha256', 'runnerSha256') if k in r}, + 'platform': r.get('environment', r.get('platform')), + 'exitCode': r.get('exitCode'), 'childExitCodes': r.get('childExitCodes'), + 'measurements': [{'mode': m.get('mode'), 'stage': m.get('stage'), + 'probe': m.get('probe'), 'roles': m.get('roles'), + 'matches': m.get('matches')} for m in r.get('measurements', [])], + 'releaseProbes': [c.get('after_exit_probe') for c in r.get('children', [])], + 'startup': [{'mode': h.get('mode'), 'event': h.get('record', {}).get('event'), + 'has_fd': h.get('record', {}).get('has_fd'), + 'initial_fd_flags': h.get('record', {}).get('initial_fd_flags'), + 'pre_spawn_fd_flags': h.get('record', {}).get('pre_spawn_fd_flags')} + for h in r.get('startup_events', [])], + 'errorCount': len(r.get('errors', [])), + 'errorKinds': [(':'.join(str(e).split(':')[:2]) if str(e).split(':')[0] + in ('default', 'inheritable') else str(e).split(':')[0])[:100] + for e in r.get('errors', [])[:10]], + 'incompleteCount': len(r.get('incomplete', r.get('pendingPids', [])))})) + PY diff --git a/docs/macos-owner-contract.md b/docs/macos-owner-contract.md index 01fb191..f828219 100644 --- a/docs/macos-owner-contract.md +++ b/docs/macos-owner-contract.md @@ -193,12 +193,12 @@ their own lifetime, zero signals, and asserts zero SIGTERM for refused cases. | Area | Item | Status | Narrow fixture or test plan | Needed before | | --- | --- | --- | --- | --- | | Inherited FD | fork without exec, both alive | Measured (section 5) | — | — | -| Inherited FD | exec of inheritable FD; `O_CLOEXEC` across exec | Measured with Python's launcher (section 5) | Repeat with a Rust `std::process::Command` child to cover the launcher Codex uses | (b) | +| Inherited FD | exec of inheritable FD; `O_CLOEXEC` across exec | Measured with Python's launcher (section 5); [Rust fixture](../experimental/127-macos-prerequisites/rust-launcher/README.md) tracks Rust 1.90/1.95 through a dedicated CI matrix | Check the workflow/PR evidence for the exact revision; fixture execution does not audit all actual Codex launch paths | (b) | | Inherited FD | `SCM_RIGHTS` passing to a sibling process | Measured (section 5) | — | — | | Inherited FD | Mach fileport passing | Not covered | C fixture: `fileport_makeport` → `fileport_makefd` in a separately started same-user actor; record bit and probe | (b) | | Inherited FD | Shared description with a visible Codex-looking sharer and a second sharer hidden | Not covered | Combine `scm_rights` with the M2 modelled omission; product must not report `safeToUnlock: true` with only one sharer killed. Expected today: possible wrong target (document, do not "fix" in the test) | (b) | | Inherited FD | Real Codex inheritance / fork paths | Not covered (source-only) | Out of scope without a real Codex binary; requires a separately approved, isolated, synthetic-home run | Holder-positive mode | -| Process identity | Holder execs in place (same PID, new image) while keeping the FD | Not covered on macOS | Actor `execve`s itself with an inheritable lock FD between two observations; assert refusal via changed command/arguments | (b) | +| Process identity | Holder execs in place (same PID, new image) while keeping the FD | [Synthetic OS observation](../experimental/127-macos-prerequisites/exec-identity/README.md) measured on macOS 27 arm64: PID/start unchanged, path/argv changed, lock retained | Production revalidation/refusal still unverified; the fixture measures OS fields and does not call the product's signal path | (b) | | Process identity | PID reuse, start-time resolution of `ps lstart` | Covered by deterministic revalidation tests ([safety-race-matrix.md](safety-race-matrix.md)); real PID reuse not forced | Unit test with an injected process table where PID and second-resolution start time match but PPID/arguments differ | — | | Process identity | Credential change of a holder after locking (setuid helper) | Not covered. The existing `--privileged-fixtures` CI job (`scripts/research-lock-attribution.py`) measures a hidden holder that runs under a different UID from the start (#100); it does not change a holder's credentials after it locks | A post-lock credential-change fixture needs a privileged helper; it could be added to that existing job | (c) decision input | | Permission | Same-user process whose libproc FD info is denied (sandboxed or hardened target) | Not covered | Holder launched under `sandbox-exec` with a deny-default profile; record lsof status, stderr, and whether the record is omitted cleanly | (b) | @@ -207,7 +207,7 @@ their own lifetime, zero signals, and asserts zero SIGTERM for refused cases. | Filesystem | APFS | Measured | — | — | | Filesystem | HFS+, ExFAT, MS-DOS local volumes | Not covered | `hdiutil create -fs ` image in the scratch root, attached with `-nobrowse -mountpoint` under the scratch; needs approval because it changes the mount table | (b) | | Filesystem | SMB/NFS/AFP and FUSE (macFUSE, rclone) | Not covered | Network/FUSE flock may be local-only or emulated. Plan: report these as unsupported for unlock unless measured; detect by `statfs` type | (c) decision input | -| OS version | macOS 15 CI (kernel 24.x) sticky-bit behavior | Not covered (only foreign-UID visibility measured there) | Run `experimental/127-macos-contract/inherit.py` and #139 M2 in a research-only CI job on `macos-15` and `macos-15-intel` | (b) | +| OS version | macOS 15 CI (kernel 24.x) sticky-bit behavior | Not covered (only foreign-UID visibility measured there); the new launcher/exec workflow does not test sticky former-holder behavior | Run `experimental/127-macos-contract/inherit.py` and #139 M2 in a research-only CI job on `macos-15` and `macos-15-intel` | (b) | ## 5. Synthetic validation (#127 fixtures) diff --git a/docs/windows-rust-evaluation.md b/docs/windows-rust-evaluation.md index 3a9f38b..9a4d12a 100644 --- a/docs/windows-rust-evaluation.md +++ b/docs/windows-rust-evaluation.md @@ -28,7 +28,7 @@ product-support result, and none establishes owner attribution or safe unlock. | Item | Value | | --- | --- | -| Release | `rust-v0.163.0` (GitHub "Latest", not a pre-release, published 2026-10-15T21:30:38Z) | +| Release | `rust-v0.163.0` (GitHub "Latest" as checked on 2026-10-16, not a pre-release, published 2026-10-15T21:30:38Z) | | Annotated tag object | `129b8ec7ee6f4511557a4856279f529025c25af4` | | Commit | `83086188ae0614bd882818ff1aa23f3194962012` | | Upstream toolchain | Rust 1.95.0 (`codex-rs/rust-toolchain.toml`) | @@ -201,7 +201,7 @@ issue with the gates below. ## 5. Follow-up units -Each unit should become a separate issue. None authorizes Windows unlock. Every +Each unit is tracked in a separate issue. None authorizes Windows unlock. Every experimental record keeps `safeToUnlock=false` until every applicable W1-W7 gate for the chosen design and supported environment is resolved and a separate production design is accepted. These are future work units; this document @@ -216,6 +216,8 @@ skip W4/W5 or promote it to a recovery tool. ### W1. File identity on NTFS and ReFS +Tracking: [#146](https://github.com/abruption/codex-unlock/issues/146). + - **Scope:** stable identity for the Codex home, `thread-writer-locks`, `.coordination.lock`, thread lock files and transcripts: volume serial plus 128-bit `FILE_ID_INFO`, link count, and attributes. Covers no-follow opening @@ -235,6 +237,8 @@ skip W4/W5 or promote it to a recovery tool. ### W2. SID and owner checks +Tracking: [#147](https://github.com/abruption/codex-unlock/issues/147). + - **Scope:** owner SID and DACL of the Codex home, the lock directory and lock and transcript files, compared with the current token user SID. Covers inherited versus explicit ACEs, and the fact that Administrators and @@ -253,6 +257,8 @@ skip W4/W5 or promote it to a recovery tool. ### W3. Process identity and start time +Tracking: [#148](https://github.com/abruption/codex-unlock/issues/148). + - **Scope:** one process handle per candidate. Identity is the handle plus `GetProcessTimes` creation time plus token SID, and liveness is `WaitForSingleObject(h, 0)`, never `STILL_ACTIVE`. Covers revalidation of the @@ -272,6 +278,8 @@ skip W4/W5 or promote it to a recovery tool. ### W4. Holder attribution (Windows counterpart of #100) +Tracking: [#149](https://github.com/abruption/codex-unlock/issues/149). + - **Scope:** decide whether a positive holder for a `LockFileEx` lock can be established at all. Candidate sources are Restart Manager, handle enumeration (which may require privileges that are out of scope) and @@ -288,6 +296,8 @@ skip W4/W5 or promote it to a recovery tool. ### W5. Cooperative termination and release path +Tracking: [#150](https://github.com/abruption/codex-unlock/issues/150). + - **Scope:** find a Windows route that lets the owner release one thread without force. Options are an upstream owner-side release (see [upstream-handoff-proposal.md](upstream-handoff-proposal.md) and section 3.4) @@ -312,6 +322,8 @@ skip W4/W5 or promote it to a recovery tool. ### W6. Medium versus High integrity tokens +Tracking: [#151](https://github.com/abruption/codex-unlock/issues/151). + - **Scope:** rerun the E1, E3 and identity cases from a Medium-integrity interactive desktop session and compare with the High-integrity SSH results. Covers cross-integrity `OpenProcess` access (`PROCESS_QUERY_LIMITED_INFORMATION` @@ -325,6 +337,8 @@ skip W4/W5 or promote it to a recovery tool. ### W7. Real Codex interoperability on Windows (prerequisite for W3 and W5) +Tracking: [#152](https://github.com/abruption/codex-unlock/issues/152). + - **Scope:** the pinned upstream release (section 2) runs in a disposable `CODEX_HOME`. A read-only observer checks the coordination and thread lock ranges and share modes against the fixture model: Rust std `LockFileEx(0, @@ -340,15 +354,17 @@ skip W4/W5 or promote it to a recovery tool. The proposed recommendation is **A go, B conditional no-go, C no-go**; no migration or product-support decision is adopted by this record. -The remaining gates are W1-W7 above. #122 can be closed once these units exist -as separate issues; that is a maintainer action not taken here. No release, +The remaining gates are W1-W7 above, tracked in #146–#152 as of 2026-10-18. +#122 is closed as the initial evaluation; its outstanding safety evidence +continues in those follow-up issues. No release, publication or support claim follows from this document. ## 7. Not done here -No Windows host was used, and no real Codex binary, Codex home or process was +For this evaluation record, no Windows host was used, and no real Codex binary, Codex home or process was run, opened or signaled. No Medium-integrity, ReFS or Defender run took place. -No upstream code was executed. No follow-up issues were filed. +No upstream code was executed. The later tracking update created #146–#152; +it did not execute their experiments. ## 8. Reproducing the citations diff --git a/experimental/127-macos-prerequisites/.gitignore b/experimental/127-macos-prerequisites/.gitignore new file mode 100644 index 0000000..bee9891 --- /dev/null +++ b/experimental/127-macos-prerequisites/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__/ +rust-launcher/target/ diff --git a/experimental/127-macos-prerequisites/README.md b/experimental/127-macos-prerequisites/README.md new file mode 100644 index 0000000..9c98580 --- /dev/null +++ b/experimental/127-macos-prerequisites/README.md @@ -0,0 +1,40 @@ +# macOS recovery prerequisites + +Research for [#127](https://github.com/abruption/codex-unlock/issues/127), +following the [owner contract](../../docs/macos-owner-contract.md). +This increment measures two prerequisites for evaluating option B, the +refusal-only `FWASLOCKED` filter. It does not implement that filter or choose +the final recovery policy. + +- [Rust launcher](rust-launcher/README.md): compare default close-on-exec + file descriptors with an explicitly inheritable control across + `std::process::Command`. Observe real flock contention and per-description + flags using the existing strict system-lsof observer. +- [Exec identity](exec-identity/README.md): replace a synthetic process image + at the same PID while retaining its lock. Compare kernel start time, + executable path and argv, then observe normal exit and lock release. + +All inputs are newly created fixture files and cooperating children. The +programs do not operate on real Codex homes or signal owners. A passing +measurement does not prove production policy refusal, sole holder control, +complete process visibility or atomic sampling. The Rust experiment pins +1.90.0 by default; the Codex source audited in the owner contract uses Rust +1.95.0. The CI matrix explicitly builds with both toolchains. No equivalence +of complete Codex launch paths follows from the fixture. + +The dedicated workflow runs on `macos-15` and `macos-15-intel`: Rust inheritance +uses both 1.90.0 and 1.95.0; the toolchain-independent exec case runs once per +OS architecture. Its configured matrix is not execution evidence until the +corresponding run passes. Raw reports contain paths/PIDs; CI prints only a +bounded summary, source/binary hashes, compiler metadata and result hash. The +raw CI report is ephemeral; its hash alone is not a retained measurement artifact. +The scripts send no termination signals. GitHub runner cancellation, job +timeouts and runner cleanup may terminate leftover synthetic fixture processes. + +Remaining prerequisites include Mach fileport transfer, hidden shared +descriptions, permission-limited observers and targets, additional lsof +versions and filesystems, and actual Codex behavior. These remain explicit +gaps in the owner contract. The same-PID experiment records observable OS +fields; it does not exercise the production revalidation decision. +Kernel start timestamps are actor-reported; `ps lstart` is independently +observed. The fixture does not detect same-path, same-argv exec. diff --git a/experimental/127-macos-prerequisites/exec-identity/README.md b/experimental/127-macos-prerequisites/exec-identity/README.md new file mode 100644 index 0000000..e7a5b5d --- /dev/null +++ b/experimental/127-macos-prerequisites/exec-identity/README.md @@ -0,0 +1,78 @@ +# Same-PID exec identity prerequisite + +Research for #127 option B, using synthetic actors only. This experiment does +not identify an actual Codex owner or authorize recovery. `safeToUnlock` is +always false. No production imports or executable allowlist changes are used. + +The runner compiles one small C actor into two distinct executable paths. The +first actor acquires a genuine private `flock`, reports its PID and kernel +process start timestamp, then waits for a one-byte command. It uses `exec` to +replace its image with the second path, intentionally retaining the open lock +descriptor. The second image reports the same fields and exits on stdin EOF. +The high-resolution kernel start timestamp is reported by the trusted actor +itself through `proc_pidinfo`; the observer independently reads `ps lstart` +at second resolution. The observer checks the executable path using `proc_pidpath`, command arguments +and `lstart` with system `ps`, and actual lock contention using an independent +descriptor. These direct observations do not certify the product's +`inspectProcess` implementation or an atomic process-identity snapshot. + +Expected table: + +| Observation | Before exec | After exec | After EOF exit | +|---|---|---|---| +| PID and kernel start timestamp | Original | Unchanged | Exited | +| `ps lstart` | Original | Unchanged | Not sampled | +| Image and argv | actor-before / before | actor-after / after | Not sampled | +| Independent flock probe | held | held | free | + +`collected` means both samples and the exit observation completed; `measured` +requires every expectation to match. `ok` also requires no errors or remaining +children. Failure preserves scratch and exits nonzero. Results include the +source and runner hashes. The result destination must not already exist and +is opened with `O_EXCL|O_NOFOLLOW`, mode 0600, before scratch creation. + +```sh +python3 -m unittest discover -s experimental/127-macos-prerequisites/exec-identity -p 'test_*.py' +python3 experimental/127-macos-prerequisites/exec-identity/run.py \ + --tmp-root '' --result '' +``` + +macOS and system clang are required. The runner creates a fresh private +`codex-exec-*` directory and removes it only after successful completion and +confirmed child exits. The actor has a 15-second polling lifetime per image; +normal exit is EOF. Diagnostic collection and cleanup waits have deadlines, +but filesystem I/O, process creation and kernel calls do not have hard time +bounds. Timeout means incomplete reporting, not guaranteed termination. No +kill calls or subprocess killing timeouts are used; abandoned diagnostic pipes +may receive OS SIGPIPE when the parent exits. Results contain private temporary +paths and PIDs and should be summarized before publication. + +Two different paths containing the same compiled program are intentional: the +experiment tests observable image-path/argv replacement, not different program +semantics. Same-path, same-argv exec is not detected by these checks. The actor +deliberately omits `FD_CLOEXEC`; this is not evidence that real Codex leaks such +a descriptor. `ps` and image sampling occur sequentially while the actor waits +for a command, rather than proving an atomic snapshot under arbitrary races. +The synthetic file is not a Codex native lock and has no native coordinator. + +## Observed local result + +One run on Darwin 27.0.0 arm64 completed with exit 0: `collected=true`, +`measured=true`, seven expectations matched, and all six child processes exited +0 with no pending processes. Both kernel start timestamps and `ps lstart` +remained equal across exec. Image path and argv changed, the independent probe +remained held, and EOF exit released the lock. Four protocol unit tests passed. +This run used actor SHA-256 +`0088c89c4a4bcedb0551c3b71e3748640b6983fb3c2fa392d841aa6dd48583ee` +and runner SHA-256 +`84da7467d84b276f826c60d92e3b510591eb3c7d8e0946b89c3fcc0079594e80`. +The raw result is retained privately; public documentation omits its paths and +PIDs. Other macOS versions are not covered by this local measurement. + +After the cleanup-reporting correction, the integrating session reran the +nominal experiment with runner SHA-256 +`14b2e16d04d2b8fdd4e6343c19e9fc8f869bb1875668db407ee9d4eadf0bb264`. +It exited 0, all seven checks passed, and no process remained pending. +The private raw-result SHA-256 is +`eaf8e5876ff3910a001d41e36ff592c428fb82a257b74d84e007afcb6f17fc24`. +CI measurements are reported separately through the workflow and PR checks. diff --git a/experimental/127-macos-prerequisites/exec-identity/actor.c b/experimental/127-macos-prerequisites/exec-identity/actor.c new file mode 100644 index 0000000..6017869 --- /dev/null +++ b/experimental/127-macos-prerequisites/exec-identity/actor.c @@ -0,0 +1,56 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Synthetic only: deliberately keep the lock FD across exec. No signals. */ +int main(int argc, char **argv) { + int fd; + if (argc == 4 && strcmp(argv[1], "before") == 0) { + fd = open(argv[2], O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, 0600); + if (fd < 0 || flock(fd, LOCK_EX | LOCK_NB) != 0) return 2; + } else if (argc == 3 && strcmp(argv[1], "after") == 0) { + char *end = NULL; + long n = strtol(argv[2], &end, 10); + if (!end || *end || n < 3 || n > 1024) return 2; + fd = (int)n; + struct stat s; + if (fstat(fd, &s) != 0 || !S_ISREG(s.st_mode)) return 2; + } else return 2; + struct proc_bsdinfo info; + if (proc_pidinfo(getpid(), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return 2; + printf("%s %d %llu %llu\n", argv[1], getpid(), + (unsigned long long)info.pbi_start_tvsec, + (unsigned long long)info.pbi_start_tvusec); + fflush(stdout); + struct timespec began, now; + clock_gettime(CLOCK_MONOTONIC, &began); + for (;;) { + clock_gettime(CLOCK_MONOTONIC, &now); + if (now.tv_sec - began.tv_sec >= 15) { close(fd); return 3; } + struct pollfd p = {STDIN_FILENO, POLLIN | POLLHUP, 0}; + int ready = poll(&p, 1, 100); + if (ready < 0 && errno != EINTR) return 2; + if (ready > 0) { + char command; + ssize_t size = read(STDIN_FILENO, &command, 1); + if (size == 0) { close(fd); return 0; } + if (size != 1) return 2; + if (command == 'x' && strcmp(argv[1], "before") == 0) { + char descriptor[32]; + snprintf(descriptor, sizeof(descriptor), "%d", fd); + execl(argv[3], argv[3], "after", descriptor, (char *)NULL); + close(fd); return 2; + } + close(fd); return 2; + } + } +} diff --git a/experimental/127-macos-prerequisites/exec-identity/run.py b/experimental/127-macos-prerequisites/exec-identity/run.py new file mode 100644 index 0000000..fd20319 --- /dev/null +++ b/experimental/127-macos-prerequisites/exec-identity/run.py @@ -0,0 +1,189 @@ +"""Darwin-only same-PID exec experiment; never signals child processes.""" +import argparse +import ctypes +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import select +import shutil +import subprocess +import sys +import tempfile +import time + + +def parse_record(line, stage, pid): + match = re.fullmatch(rb"(before|after) ([0-9]+) ([0-9]+) ([0-9]+)\n", line) + if not match or match[1].decode() != stage or int(match[2]) != pid: + raise ValueError("invalid actor record") + if int(match[3]) <= 0 or int(match[4]) >= 1000000: + raise ValueError("invalid start timestamp") + return {"stage": stage, "pid": pid, "start": [int(match[3]), int(match[4])]} + + +class Reader: + def __init__(self, stream): + self.stream, self.pending, self.total = stream, b"", 0 + + def line(self, seconds=3): + deadline = time.monotonic() + seconds + while True: + if b"\n" in self.pending: + line, self.pending = self.pending.split(b"\n", 1) + if len(line) > 1024: + raise ValueError("line limit") + return line + b"\n" + if len(self.pending) > 1024: + raise ValueError("line limit") + remaining = deadline - time.monotonic() + if remaining <= 0 or not select.select([self.stream], [], [], remaining)[0]: + raise TimeoutError("record timeout") + chunk = os.read(self.stream.fileno(), 4096) + if not chunk: + raise ValueError("EOF before complete record") + self.total += len(chunk) + if self.total > 8192: + raise ValueError("stream limit") + self.pending += chunk + + +def capture(argv, children, seconds=15): + child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + children.append(child) + output = bytearray() + deadline = time.monotonic() + seconds + eof = False + while time.monotonic() < deadline: + if not eof and select.select([child.stdout], [], [], 0.05)[0]: + block = os.read(child.stdout.fileno(), 8192) + if not block: + eof = True + output.extend(block) + if len(output) > 65536: + raise ValueError("diagnostic output limit") + if eof and child.poll() is not None: + if child.returncode != 0: + raise ValueError("diagnostic failed: " + bytes(output).decode(errors="replace")) + child.stdout.close() + return bytes(output).decode().strip() + if eof: + time.sleep(0.01) + raise TimeoutError("diagnostic incomplete; no signal sent") + + +def probe(path): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return "held" + fcntl.flock(fd, fcntl.LOCK_UN) + return "free" + finally: + os.close(fd) + + +def image(pid): + library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) + library.proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32] + library.proc_pidpath.restype = ctypes.c_int + buffer = ctypes.create_string_buffer(4096) + if library.proc_pidpath(pid, buffer, len(buffer)) <= 0: + raise OSError("proc_pidpath failed") + return os.fsdecode(buffer.value) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--result", required=True) + parser.add_argument("--tmp-root", required=True) + args = parser.parse_args() + if sys.platform != "darwin": + raise SystemExit("Darwin required") + result_fd = os.open(args.result, os.O_WRONLY | os.O_CREAT | os.O_EXCL | + os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + children, actor, scratch = [], None, None + result = {"ok": False, "collected": False, "measured": False, + "safeToUnlock": False, "errors": [], "observations": []} + try: + scratch = Path(tempfile.mkdtemp(prefix="codex-exec-", dir=args.tmp_root)).resolve() + source = Path(__file__).with_name("actor.c") + result["sourceSha256"] = hashlib.sha256(source.read_bytes()).hexdigest() + result["runnerSha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + result["platform"] = {"system": os.uname().sysname, "release": os.uname().release, + "machine": os.uname().machine} + a, b, lock = scratch / "actor-before", scratch / "actor-after", scratch / "fixture.lock" + capture(["/usr/bin/clang", "-Wall", "-Wextra", "-Werror", str(source), "-o", str(a)], children) + shutil.copyfile(a, b) + b.chmod(0o700) + actor = subprocess.Popen([str(a), "before", str(lock), str(b)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + children.append(actor) + reader = Reader(actor.stdout) + for stage, executable in [("before", a), ("after", b)]: + row = parse_record(reader.line(), stage, actor.pid) + row["image"] = image(actor.pid) + row["args"] = capture(["/bin/ps", "-ww", "-p", str(actor.pid), "-o", "args="], children, 3) + row["lstart"] = capture(["/bin/ps", "-p", str(actor.pid), "-o", "lstart="], children, 3) + row["probe"] = probe(lock) + row["expectedImage"] = str(executable) + result["observations"].append(row) + if stage == "before": + actor.stdin.write(b"x") + actor.stdin.flush() + actor.stdin.close() + actor.wait(timeout=3) + tail = actor.stdout.read(8193) + if tail or reader.pending: + raise ValueError("unexpected trailing actor output") + result["exitCode"] = actor.returncode + result["afterExitProbe"] = probe(lock) + result["collected"] = True + before, after = result["observations"] + checks = { + "samePid": before["pid"] == after["pid"], + "sameKernelStart": before["start"] == after["start"], + "samePsStart": bool(before["lstart"]) and before["lstart"] == after["lstart"], + "imagesMatch": all(row["image"] == row["expectedImage"] for row in [before, after]), + "argvChanged": before["args"] != after["args"] and " before " in before["args"] and " after " in after["args"], + "lockHeldAcrossExec": before["probe"] == after["probe"] == "held", + "normalExitAndRelease": actor.returncode == 0 and result["afterExitProbe"] == "free", + } + result["checks"] = checks + result["measured"] = all(checks.values()) + except Exception as error: + result["errors"].append(f"{type(error).__name__}: {error}") + finally: + if actor and actor.stdin and not actor.stdin.closed: + try: + actor.stdin.close() + except OSError as error: + result["errors"].append(f"stdin_cleanup:{type(error).__name__}") + deadline = time.monotonic() + 3 + while any(child.poll() is None for child in children) and time.monotonic() < deadline: + time.sleep(0.02) + result["pendingPids"] = [child.pid for child in children if child.poll() is None] + result["childExitCodes"] = [child.poll() for child in children] + result["ok"] = result["collected"] and result["measured"] and not result["errors"] and not result["pendingPids"] + if result["ok"] and scratch: + try: + shutil.rmtree(scratch) + except OSError as error: + result["errors"].append(f"scratch_cleanup:{type(error).__name__}") + result["ok"] = False + result["preservedScratch"] = str(scratch) + elif scratch: + result["preservedScratch"] = str(scratch) + with os.fdopen(result_fd, "w") as output: + json.dump(result, output, indent=2) + output.write("\n") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/experimental/127-macos-prerequisites/exec-identity/test_run.py b/experimental/127-macos-prerequisites/exec-identity/test_run.py new file mode 100644 index 0000000..a12974f --- /dev/null +++ b/experimental/127-macos-prerequisites/exec-identity/test_run.py @@ -0,0 +1,40 @@ +import os +import unittest +from run import Reader, parse_record + + +class ProtocolTests(unittest.TestCase): + def test_record_validation(self): + self.assertEqual(parse_record(b"before 42 100 200\n", "before", 42)["start"], [100, 200]) + for value in [b"before 43 100 200\n", b"after 42 100 200\n", + b"before 42 0 0\n", b"before 42 100 1000000\n", + b"before 42 100 200", b"before 42 100 200\nextra"]: + with self.assertRaises(ValueError): + parse_record(value, "before", 42) + + def reader(self, content): + read_fd, write_fd = os.pipe() + os.write(write_fd, content) + os.close(write_fd) + stream = os.fdopen(read_fd, "rb", buffering=0) + self.addCleanup(stream.close) + return Reader(stream) + + def test_multiple_records(self): + reader = self.reader(b"one\ntwo\n") + self.assertEqual(reader.line(), b"one\n") + self.assertEqual(reader.line(), b"two\n") + + def test_line_boundary(self): + self.assertEqual(len(self.reader(b"a" * 1024 + b"\n").line()), 1025) + for value in [b"a" * 1025 + b"\n", b"a" * 1025]: + with self.assertRaises(ValueError): + self.reader(value).line() + + def test_incomplete_record(self): + with self.assertRaises(ValueError): + self.reader(b"partial").line() + + +if __name__ == "__main__": + unittest.main() diff --git a/experimental/127-macos-prerequisites/rust-launcher/.gitignore b/experimental/127-macos-prerequisites/rust-launcher/.gitignore new file mode 100644 index 0000000..6a50617 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/.gitignore @@ -0,0 +1,2 @@ +/target/ +/__pycache__/ diff --git a/experimental/127-macos-prerequisites/rust-launcher/Cargo.lock b/experimental/127-macos-prerequisites/rust-launcher/Cargo.lock new file mode 100644 index 0000000..61a45d4 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "macos-command-inheritance-lab" +version = "0.0.0" diff --git a/experimental/127-macos-prerequisites/rust-launcher/Cargo.toml b/experimental/127-macos-prerequisites/rust-launcher/Cargo.toml new file mode 100644 index 0000000..799c8a2 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "macos-command-inheritance-lab" +version = "0.0.0" +edition = "2024" +rust-version = "1.90" +publish = false + +[dependencies] diff --git a/experimental/127-macos-prerequisites/rust-launcher/README.md b/experimental/127-macos-prerequisites/rust-launcher/README.md new file mode 100644 index 0000000..e6b8ee1 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/README.md @@ -0,0 +1,93 @@ +# Rust `Command` descriptor inheritance prerequisite + +This unpublished, dependency-free Rust 1.90.0 experiment tests one prerequisite +for #127 option B. A synthetic parent opens a fresh file with `std::fs`, obtains +a real exclusive `File::try_lock`, and launches this same executable using +`std::process::Command`. A Python observer separately probes the file using +macOS `flock` and records `/usr/sbin/lsof +fg` flags. + +The default case preserves the descriptor's `FD_CLOEXEC`. The control clears +that flag with Darwin `fcntl` before `Command::spawn`. The child verifies the +inherited descriptor's device/inode before retaining it. The parent then closes +its own descriptor **without explicitly unlocking the shared description**. + +Expected observations (not results until a run passes): + +| Case / stage | Probe | Parent FWASLOCKED | Child FWASLOCKED | +| --- | --- | --- | --- | +| Default / before parent close | held | present | absent | +| Default / after parent close | free | absent | absent | +| Explicit inheritance / before parent close | held | present | present | +| Explicit inheritance / after parent close | held | absent | present | + +The runner checks all four rows, verified child/parent identities, observed +initial and pre-spawn descriptor flags, the child's descriptor result, strict lsof parsing, unexpected +openers, stderr, both process exit outcomes, and clean stdout EOF after the +terminal child-exit record, followed by an independent free-lock probe after +both processes exit. Extra complete or partial output is rejected. +Missing or malformed evidence +does not count as absence. Any mismatch or incomplete exit makes the run fail. +FWASLOCKED remains acquisition-history evidence; no holder or recovery +eligibility conclusion is produced. + +## Run + +Run from this directory on macOS with Rust 1.90.0 already available: + +```sh +cargo +1.90.0 build --locked +python3 -B -m unittest -v test_run.py +python3 -B run.py \ + --binary "$PWD/target/debug/macos-command-inheritance-lab" \ + --scratch-root "$RUNNER_TEMP" \ + --result "$RUNNER_TEMP/rust-inheritance.json" +``` + +`--scratch-root` must name an existing writable temporary root; the runner +creates a fresh 0700 child directory. `--result` must be a new path outside that +new directory; existing files and final-component symlinks are refused. Results +are written with mode 0600. The supplied binary must be the trusted fixture +built above. Its hash and both Python source hashes are recorded. Preserve the +source revision and `rustc +1.90.0 --version --verbose` with CI evidence; a binary +hash alone does not attest the compiler or source used. + +The runner reuses the repository's existing +`experimental/127-macos-contract/inherit.py` strict lsof parser, byte-limited +framing, bounded diagnostic waiting, and real flock probe. Run from a full +checkout so that module is available. The package has no third-party Rust +dependencies and `publish = false`. + +## Lifetime, scope, and validation limits + +Only newly created synthetic files and our own parent/child are used. Normal +cleanup is stdin EOF. Rust checks a 15-second parent lifetime and a 20-second +keeper lifetime while waiting on its input channel. The parent gives the child +three seconds to finish and reports an unconfirmed exit as incomplete. Python +also bounds its waits and never sends a termination signal. Synchronous OS +calls, process creation, and the standard-library lock operation are not given +a hard wall-clock guarantee. A stalled process may remain; results and scratch +are always retained for inspection. Closing a diagnostic's pipes when the +runner exits can cause a later write to receive kernel SIGPIPE. There is no +claim that every process necessarily exits. + +This does not execute a Codex binary, audit all launchers, +validate a real Codex descriptor lifetime, or establish holder attribution. +There is no native coordination-file experiment here: the synthetic actors are +held steady at explicit protocol stages. The observation is local macOS +process behavior, not a production policy change. The control shows what +explicit inheritance permits; it cannot establish that Codex uses that path. + +Local preparation: Python AST checks and the initial four focused expectation tests passed. +Those tests reject missing fields, failed scans, unexpected openers, and wrong +probe/role results. **Rust compilation and the four runtime rows were not run +locally** because rustc/cargo were absent from PATH and the checked conventional +installation locations. There is no runtime success claim until the pinned +compiler and nominal experiment run in CI. No toolchain was installed locally. +The workflow explicitly selects Rust 1.90.0 and 1.95.0 on both macOS runner +architectures; its logs identify the actual compiler. The report's +`minimum_rust` is the crate requirement, not a measured compiler version. +Additional regressions check that a missing binary produces a nonzero +result with a structured failure report, and that missing measurements or +failed/incomplete cleanup cannot satisfy the final predicate. Set +`RESEARCH_TMP_ROOT` to an existing approved temporary root when running all +seven helper tests. diff --git a/experimental/127-macos-prerequisites/rust-launcher/run.py b/experimental/127-macos-prerequisites/rust-launcher/run.py new file mode 100644 index 0000000..4f4f0f9 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/run.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Run only the supplied synthetic Rust binary; never discover real owners.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace + +SHARED = Path(__file__).resolve().parents[2] / "127-macos-contract" / "inherit.py" +spec = importlib.util.spec_from_file_location("macos_contract", SHARED) +shared = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shared) + + +def expectation(mode, stage): + inherited = mode == "inheritable" + return { + "probe": "held" if stage == "before_close" or inherited else "free", + "roles": {"parent": "bit" if stage == "before_close" else "absent", + "child": "bit" if inherited else "absent"}, + "scan_valid": True, "unexpected_pids": 0, + } + + +def matches(observed, expected): + return all(key in observed and observed[key] == value for key, value in expected.items()) + + +def ready_errors(mode, parent_pid, keeper, ready): + errors = [] + if type(ready.get("pid")) is not int or ready["pid"] != parent_pid: + errors.append("ready_parent_pid_mismatch") + child_pid = ready.get("child_pid") + if type(child_pid) is not int or child_pid <= 0 or child_pid == parent_pid: + errors.append("ready_child_pid_invalid") + if type(keeper.get("pid")) is not int or keeper["pid"] != child_pid: + errors.append("keeper_pid_mismatch") + if keeper.get("has_fd") is not (mode == "inheritable"): + errors.append("keeper_fd_inheritance_mismatch") + initial = ready.get("initial_fd_flags") + before_spawn = ready.get("pre_spawn_fd_flags") + if type(initial) is not int or initial < 0: + errors.append("initial_fd_flags_invalid") + elif initial & 1 == 0: + errors.append("initial_cloexec_missing") + if type(before_spawn) is not int or before_spawn < 0: + errors.append("pre_spawn_fd_flags_invalid") + elif bool(before_spawn & 1) != (mode == "default"): + errors.append("pre_spawn_cloexec_mismatch") + return errors + + +def complete(report): + rows = report.get("measurements", []) + children = report.get("children", []) + expected_rows = {(m, s) for m in ("default", "inheritable") + for s in ("before_close", "after_close")} + return (len(rows) == 4 and + {(r.get("mode"), r.get("stage")) for r in rows} == expected_rows and + all(r.get("matches") is True for r in rows) and len(children) == 2 and + {c.get("mode") for c in children} == {"default", "inheritable"} and + all(type(c.get("code")) is int and c["code"] == 0 and + c.get("keeper_exit") == {"event": "child_exit", "ok": True, "code": 0} and + c.get("after_exit_probe") == "free" for c in children) and + report.get("errors") == [] and report.get("incomplete") == []) + + +def event(reader, expected): + kind, line = reader.read_line(time.monotonic() + 5) + if kind != "line": + raise RuntimeError(f"protocol:{kind}") + value = json.loads(line) + if not isinstance(value, dict) or value.get("event") != expected: + raise RuntimeError(f"expected:{expected}:{value}") + return value + + +def require_eof(reader): + kind, _ = reader.read_line(time.monotonic() + 1) + if kind != "eof": + raise RuntimeError(f"unexpected_stdout_tail:{kind}") + + +def run(binary, result_path, tmp_root): + # Open result exclusively before starting any process or fixture. + result_fd = os.open(result_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | + os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + report = {"ok": False, "safeToUnlock": False, "measurements": [], "errors": [], "children": [], + "startup_events": [], + "environment": {"system": platform.system(), "release": platform.release(), + "machine": platform.machine(), "python": sys.version, + "minimum_rust": "1.90.0", + "actual_rust": "not inferred; record compiler metadata in CI"}} + state = SimpleNamespace(pending=[], incomplete=[]) + scratch = None + try: + report["script_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + report["shared_sha256"] = hashlib.sha256(SHARED.read_bytes()).hexdigest() + report["binary_sha256"] = hashlib.sha256(Path(binary).read_bytes()).hexdigest() + scratch = Path(tempfile.mkdtemp(prefix="codex-rust-inherit-", dir=tmp_root)) + if scratch.stat().st_mode & 0o777 != 0o700: + raise RuntimeError("scratch_mode") + for mode in ("default", "inheritable"): + lock = scratch / f"{mode}.lock" + # stderr goes to a private regular file, so a full pipe cannot stall + # the Rust parent. This fixed fixture emits only bounded messages. + with (scratch / f"{mode}.stderr").open("xb") as err: + proc = subprocess.Popen([binary, mode, str(lock)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=err, bufsize=0) + reader = shared.Framed(proc.stdout.fileno()) + child_exit = None + try: + keeper = event(reader, "keeper") + report["startup_events"].append({"mode": mode, "record": keeper}) + ready = event(reader, "ready") + report["startup_events"].append({"mode": mode, "record": ready}) + failures = ready_errors(mode, proc.pid, keeper, ready) + if failures: + report["errors"].extend(f"{mode}:{failure}" for failure in failures) + raise RuntimeError("startup_validation_failed") + roles = {"parent": proc.pid, "child": ready["child_pid"]} + for stage in ("before_close", "after_close"): + if stage == "after_close": + proc.stdin.write(b"close\n") + event(reader, "closed") + observed = shared.observe(state, str(lock), stage, roles) + observed["mode"] = mode + observed["expected"] = expectation(mode, stage) + observed["matches"] = matches(observed, observed["expected"]) + report["measurements"].append(observed) + finally: + proc.stdin.close() + try: + child_exit = event(reader, "child_exit") + except Exception as error: + report["errors"].append(str(error)) + try: + code = proc.wait(timeout=5) # wait never sends a signal + except subprocess.TimeoutExpired: + code = None + state.incomplete.append(f"rust_parent:{proc.pid}") + report["children"].append({"mode": mode, "pid": proc.pid, "code": code, + "keeper_exit": child_exit}) + if code != 0 or child_exit != {"event": "child_exit", "ok": True, "code": 0}: + report["errors"].append("exit_unconfirmed_or_failed") + else: + released = shared.probe(str(lock)) + report["children"][-1]["after_exit_probe"] = released + if released != "free": + report["errors"].append(f"lock_not_free_after_exit:{mode}:{released}") + try: + require_eof(reader) + except Exception as error: + report["errors"].append(str(error)) + proc.stdout.close() + if (scratch / f"{mode}.stderr").stat().st_size: + report["errors"].append(f"stderr:{mode}") + except Exception as error: + report["errors"].append(f"{type(error).__name__}:{error}") + finally: + report["incomplete"] = state.incomplete + report["pending_diagnostics"] = [{"pid": p["pid"], "exit": p["proc"].poll()} + for p in state.pending] + report["scratch"] = str(scratch) if scratch else None + report["ok"] = complete(report) + with os.fdopen(result_fd, "w") as output: + json.dump(report, output, indent=2) + output.write("\n") + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--binary", required=True) + parser.add_argument("--result", required=True) + parser.add_argument("--scratch-root", required=True) + args = parser.parse_args() + if platform.system() != "Darwin": + parser.error("macOS only") + raise SystemExit(run(str(Path(args.binary).resolve()), args.result, args.scratch_root)) diff --git a/experimental/127-macos-prerequisites/rust-launcher/rust-toolchain.toml b/experimental/127-macos-prerequisites/rust-launcher/rust-toolchain.toml new file mode 100644 index 0000000..c3891f0 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.90.0" +profile = "minimal" diff --git a/experimental/127-macos-prerequisites/rust-launcher/src/main.rs b/experimental/127-macos-prerequisites/rust-launcher/src/main.rs new file mode 100644 index 0000000..f967937 --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/src/main.rs @@ -0,0 +1,130 @@ +//! Synthetic macOS std::process::Command inheritance experiment; unpublished. +use std::fs::{File, OpenOptions}; +use std::io::{self, BufRead, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::MetadataExt; +use std::process::{Child, Command, ExitCode, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +// Darwin fcntl constants. The executable refuses other operating systems. +const F_GETFD: i32 = 1; +const F_SETFD: i32 = 2; +const FD_CLOEXEC: i32 = 1; +unsafe extern "C" { fn fcntl(fd: i32, cmd: i32, ...) -> i32; } + +fn lines(mut reader: R) -> Receiver> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { if tx.send(Ok(line.trim().to_owned())).is_err() { break; } } + Err(e) => { let _ = tx.send(Err(e)); break; } + } + }); + rx +} + +fn emit(value: &str) -> io::Result<()> { + println!("{value}"); + io::stdout().flush() +} + +fn finish(child: &mut Child) -> io::Result<()> { + drop(child.stdin.take()); // EOF only; never kill a child. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait()? { + emit(&format!("{{\"event\":\"child_exit\",\"ok\":{},\"code\":{}}}", + status.success(), status.code().map_or("null".to_owned(), |n| n.to_string())))?; + return if status.success() { Ok(()) } else { Err(io::Error::other("child failed")) }; + } + if Instant::now() >= deadline { + emit(&format!("{{\"event\":\"incomplete\",\"pid\":{}}}", child.id()))?; + return Err(io::Error::other("child exit unconfirmed")); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn keeper(args: &[String]) -> io::Result<()> { + let fd: i32 = args[2].parse().map_err(io::Error::other)?; + let dev: u64 = args[3].parse().map_err(io::Error::other)?; + let ino: u64 = args[4].parse().map_err(io::Error::other)?; + let mut inherited = None; + // If startup reused the numeric descriptor, identity comparison prevents + // counting that unrelated descriptor as the fixture file. + if unsafe { fcntl(fd, F_GETFD) } >= 0 { + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file.metadata()?; + if metadata.dev() == dev && metadata.ino() == ino { + inherited = Some(file); + } else { + std::mem::forget(file); // Do not close an unrelated runtime FD. + } + } + emit(&format!("{{\"event\":\"keeper\",\"pid\":{},\"has_fd\":{}}}", + std::process::id(), inherited.is_some()))?; + let input = lines(io::BufReader::new(io::stdin())); + match input.recv_timeout(Duration::from_secs(20)) { + Err(mpsc::RecvTimeoutError::Disconnected) => Ok(()), + Ok(Ok(line)) if line == "exit" => Ok(()), + _ => Err(io::Error::other("keeper lifetime or protocol failure")), + } + // inherited File drops on every return, without an explicit LOCK_UN: + // unlocking a shared description would also unlock the parent's lock. +} + +fn parent(args: &[String]) -> io::Result<()> { + if args.len() != 3 || !["default", "inheritable"].contains(&args[1].as_str()) { + return Err(io::Error::other("usage: lab default|inheritable NEW_FILE")); + } + let file = OpenOptions::new().read(true).write(true).create_new(true).open(&args[2])?; + file.try_lock().map_err(io::Error::other)?; + let fd = file.as_raw_fd(); + let flags = unsafe { fcntl(fd, F_GETFD) }; + if flags < 0 { return Err(io::Error::last_os_error()); } + if args[1] == "inheritable" && unsafe { fcntl(fd, F_SETFD, flags & !FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + let spawn_flags = unsafe { fcntl(fd, F_GETFD) }; + if spawn_flags < 0 { return Err(io::Error::last_os_error()); } + let metadata = file.metadata()?; + let mut child = Command::new(std::env::current_exe()?) + .args(["keeper", &fd.to_string(), &metadata.dev().to_string(), &metadata.ino().to_string()]) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn()?; + let child_output = lines(io::BufReader::new(child.stdout.take().unwrap())); + let result = (|| { + let ready = child_output.recv_timeout(Duration::from_secs(5)).map_err(io::Error::other)??; + emit(&ready)?; + emit(&format!("{{\"event\":\"ready\",\"pid\":{},\"child_pid\":{},\"initial_fd_flags\":{},\"pre_spawn_fd_flags\":{}}}", + std::process::id(), child.id(), flags, spawn_flags))?; + let input = lines(io::BufReader::new(io::stdin())); + let deadline = Instant::now() + Duration::from_secs(15); + let mut held_file = Some(file); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + match input.recv_timeout(remaining) { + Ok(Ok(line)) if line == "close" && held_file.is_some() => { + drop(held_file.take()); + emit("{\"event\":\"closed\"}")?; + } + Ok(Ok(line)) if line == "exit" => return Ok(()), + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + _ => return Err(io::Error::other("parent lifetime or protocol failure")), + } + } + })(); + let cleanup = finish(&mut child); + result.and(cleanup) +} + +fn main() -> ExitCode { + if !cfg!(target_os = "macos") { eprintln!("macOS only"); return ExitCode::from(3); } + let args: Vec = std::env::args().collect(); + let result = if args.get(1).is_some_and(|v| v == "keeper") && args.len() == 5 { + keeper(&args) + } else { parent(&args) }; + match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("{e}"); ExitCode::from(1) } } +} diff --git a/experimental/127-macos-prerequisites/rust-launcher/test_run.py b/experimental/127-macos-prerequisites/rust-launcher/test_run.py new file mode 100644 index 0000000..c1e9fee --- /dev/null +++ b/experimental/127-macos-prerequisites/rust-launcher/test_run.py @@ -0,0 +1,99 @@ +import json +import copy +import os +from pathlib import Path +import tempfile +import unittest +import run + + +class Expectations(unittest.TestCase): + def test_complete_requires_rows_exits_free_probes_and_clean_collection(self): + good = {"errors": [], "incomplete": [], + "measurements": [{"mode": mode, "stage": stage, "matches": True} + for mode in ("default", "inheritable") + for stage in ("before_close", "after_close")], + "children": [{"mode": mode, "code": 0, "after_exit_probe": "free", + "keeper_exit": {"event": "child_exit", "ok": True, "code": 0}} + for mode in ("default", "inheritable")]} + self.assertTrue(run.complete(good)) + for fault in ("missing_row", "mismatch", "missing_child", "unknown_exit", + "keeper_failed", "held_after_exit", "incomplete", "error"): + bad = copy.deepcopy(good) + if fault == "missing_row": + bad["measurements"].pop() + elif fault == "mismatch": + bad["measurements"][0]["matches"] = False + elif fault == "missing_child": + bad["children"].pop() + elif fault == "unknown_exit": + bad["children"][0]["code"] = None + elif fault == "keeper_failed": + bad["children"][0]["keeper_exit"]["ok"] = False + elif fault == "held_after_exit": + bad["children"][0]["after_exit_probe"] = "held" + elif fault == "incomplete": + bad["incomplete"].append("child") + else: + bad["errors"].append("scan") + with self.subTest(fault=fault): + self.assertFalse(run.complete(bad)) + + def test_ready_validation_uses_observed_flags_and_specific_failures(self): + keeper = {"pid": 11, "has_fd": False} + ready = {"pid": 10, "child_pid": 11, "initial_fd_flags": 1, + "pre_spawn_fd_flags": 1} + self.assertEqual(run.ready_errors("default", 10, keeper, ready), []) + self.assertEqual(run.ready_errors("inheritable", 10, keeper | {"has_fd": True}, + ready | {"pre_spawn_fd_flags": 0}), []) + for change, expected in (({"pid": 12}, "ready_parent_pid_mismatch"), + ({"initial_fd_flags": 0}, "initial_cloexec_missing"), + ({"pre_spawn_fd_flags": 0}, "pre_spawn_cloexec_mismatch"), + ({"initial_fd_flags": True}, "initial_fd_flags_invalid")): + self.assertIn(expected, run.ready_errors("default", 10, keeper, ready | change)) + self.assertIn("keeper_fd_inheritance_mismatch", run.ready_errors( + "default", 10, keeper | {"has_fd": True}, ready)) + + @unittest.skipUnless(os.environ.get("RESEARCH_TMP_ROOT"), "research temp root required") + def test_missing_binary_writes_failure_report(self): + with tempfile.TemporaryDirectory(prefix="codex-rust-report-", dir=os.environ["RESEARCH_TMP_ROOT"]) as root: + result = Path(root) / "result.json" + self.assertEqual(run.run(str(Path(root) / "missing-binary"), result, root), 1) + report = json.loads(result.read_text()) + self.assertFalse(report["ok"]) + self.assertFalse(report["safeToUnlock"]) + self.assertEqual(report["children"], []) + self.assertTrue(any("FileNotFoundError" in e for e in report["errors"])) + + def test_stdout_tail_requires_clean_eof(self): + class Reader: + def __init__(self, kind): + self.kind = kind + + def read_line(self, _deadline): + return self.kind, b'{}' + + run.require_eof(Reader("eof")) + for kind in ("line", "unterminated", "timeout", "limit"): + with self.assertRaisesRegex(RuntimeError, "unexpected_stdout_tail"): + run.require_eof(Reader(kind)) + + def test_default_closes_after_parent(self): + self.assertEqual(run.expectation("default", "after_close")["probe"], "free") + self.assertEqual(run.expectation("default", "before_close")["roles"]["child"], "absent") + + def test_explicit_inheritance_keeps_shared_description(self): + self.assertEqual(run.expectation("inheritable", "after_close")["probe"], "held") + self.assertEqual(run.expectation("inheritable", "after_close")["roles"], + {"parent": "absent", "child": "bit"}) + + def test_missing_or_uncertain_observation_is_failure(self): + expected = run.expectation("default", "after_close") + for change in ({"probe": "held"}, {"scan_valid": False}, + {"unexpected_pids": 1}, {"roles": {}}): + self.assertFalse(run.matches(expected | change, expected)) + self.assertFalse(run.matches({}, expected)) + + +if __name__ == "__main__": + unittest.main()