diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index 6966595704..f8f9d3454b 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -386,6 +386,17 @@ or `/proc/self/fd/7` in the wrapper argv. That form is admitted only when the wrapper's corresponding descriptor and executable both have the exact sealed snapshot Node inode. The guard and wrapper entrypoint paths remain exact. Missing, foreign or other descriptor numbers fail before signalling. +Production may also stage the runner executable as a link to the image's +verified installation. The fault helper reads the resolved regular file, checks +that the named link remained unchanged, and still requires its pinned hash and +exact `/proc//exe` inode. Link replacement, missing targets and a +different executable fail admission. Linux calibration covers this installation +form as well as a copied runner. + +The agent-memory fixture treats its quoted JSON string as UTF-8 file content, +including a final line-feed byte (`0x0A`). Native readback and the managed-file +API must retain those exact bytes across a new task and controller restart. +The prompt makes that decoding explicit; the byte graders remain unchanged. The runtime itself must emit `runtime_request.expired` for the original callback with `provider_process_lost` and `replayAllowed:false`, followed by native turn diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index eb8c04a1af..d3addc69e1 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1114,7 +1114,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ groups: ["native"], profiles: extendedHarnessProfiles.filter(profile => profile.qualificationCandidate === "pi"), environments: runnerEnvironments, tasks: piNativeTasks, expectedMatrixSize: 10, excludedExecutionIds: ["pi-native.runner-acpx-pi.daytona.restrictive-denial", "pi-native.runner-acpx-pi.local.native-pending-provider-death"], - definitionMetadata: { version: 7, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, agentMemoryContent: "utf8-nonce-plus-final-lf", providerDeath: "daytona-exact-pi-child-pidfd-production-expiry", providerFaultExecutable: "snapshot-node-inode-with-held-bootstrap-fd-3-or-7", remoteBootstrapApproval: "exact-published-native-read-public-accept-once-v1", remoteDenyAll: "unsupported-native-bootstrap-read-is-denied", remoteEvidence: "owned-lease-sealed-observer", pendingControllerRestart: "same-live-native-request" }, + definitionMetadata: { version: 8, qualification: "pending", scheduling: "explicit-only", profileVersion: QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, agentMemoryContent: "utf8-nonce-plus-final-lf", providerDeath: "daytona-exact-pi-child-pidfd-production-expiry", providerFaultExecutable: "stable-preinstalled-runner-link-and-snapshot-node-inode-with-held-bootstrap-fd-3-or-7", remoteBootstrapApproval: "exact-published-native-read-public-accept-once-v1", remoteDenyAll: "unsupported-native-bootstrap-read-is-denied", remoteEvidence: "owned-lease-sealed-observer", pendingControllerRestart: "same-live-native-request" }, }, { id: "native-active-stop", label: "Stop an unanswered native permission", manualOnly: true, diff --git a/tests/runner-e2e/pi-provider-fault.py b/tests/runner-e2e/pi-provider-fault.py index 693fa5a089..56537a8b53 100644 --- a/tests/runner-e2e/pi-provider-fault.py +++ b/tests/runner-e2e/pi-provider-fault.py @@ -72,6 +72,20 @@ def digest(data): return hashlib.sha256(data).hexdigest() +def checked_runner_executable(path): + # Production stages a verified preinstalled runner with ln -sfn. Admit + # that named link only while its identity and resolved regular file remain + # stable; inspect still requires both the pinned hash and /proc/exe inode. + before = os.lstat(path) + require(stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode), 'runner_file_shape') + resolved = os.path.realpath(path) + content, inode = checked_file(resolved, 256 * 1024 * 1024) + after = os.lstat(path) + identity = lambda s: (s.st_dev, s.st_ino, s.st_mode, s.st_mtime_ns, s.st_ctime_ns) + require(identity(before) == identity(after) and os.path.realpath(path) == resolved, 'runner_link_changed') + return content, inode + + def parse_closure_metadata(content, expected_pin): # Match production parseNativeAcpxDistributionEntries: the profile pins # canonical entries, not the formatting or outer JSON file bytes. @@ -111,7 +125,7 @@ def inspect(config): and flag(root_args, '--environment-lease-id') == config['runtimeEnvironmentLeaseId'] and flag(root_args, '--lifecycle-mode') == 'per_turn', 'root_binding') require(re.fullmatch(re.escape(runtime_root) + r'/sessions/[a-f0-9]{64}/runner', flag(root_args, '--state-dir')), 'root_session') - runner_bytes, runner_inode = checked_file(runner, 256 * 1024 * 1024) + runner_bytes, runner_inode = checked_runner_executable(runner) executable = os.stat(f'/proc/{root["pid"]}/exe') require((executable.st_dev, executable.st_ino) == runner_inode and 'sha256:' + digest(runner_bytes) == config['runnerdSha256'], 'runner_executable') closure_bytes, _ = checked_file(PACK + '/provider-assets/pi/linux-x64/native-closure.json', 4 * 1024 * 1024) diff --git a/tests/runner-e2e/pi-provider-fault.test.py b/tests/runner-e2e/pi-provider-fault.test.py index 52a558f96c..281c78cbc4 100644 --- a/tests/runner-e2e/pi-provider-fault.test.py +++ b/tests/runner-e2e/pi-provider-fault.test.py @@ -98,6 +98,7 @@ class IdentityTests(unittest.TestCase): self.args = {21: [RUNTIME + '/bin/paperclip-runnerd', '--run-id', 'run', '--environment-lease-id', 'workspace-id', '--lifecycle-mode', 'per_turn', '--state-dir', RUNTIME + '/sessions/' + 'a' * 64 + '/runner'], 22: [DIST + '/' + fault.NODE, '--require', DIST + '/' + fault.GUARD, DIST + '/pi-entry.cjs'], 23: ['pi']} self.patches = [patch.object(fault, 'proc', side_effect=lambda pid, boot: self.table[pid]), patch.object(fault, 'argv', side_effect=lambda pid: self.args[pid]), + patch.object(fault, 'checked_runner_executable', side_effect=lambda p: self.files[p]), patch.object(fault, 'checked_file', side_effect=lambda p, *a: self.files[p]), patch.object(Path, 'read_text', return_value=BOOT), patch.object(os, 'listdir', return_value=['21', '22', '23']), patch.object(os, 'getpgid', return_value=21), patch.object(os, 'lstat', return_value=SimpleNamespace(st_mode=0o40500)), patch.object(os.path, 'realpath', side_effect=lambda p: p), @@ -158,6 +159,43 @@ class IdentityTests(unittest.TestCase): with patch.object(os, 'listdir', return_value=['21', '22', '23', '24']), self.assertRaisesRegex(RuntimeError, 'unique_pi_child'): fault.inspect(self.config) +class RunnerExecutableTests(unittest.TestCase): + def test_regular_and_preinstalled_link_resolve_to_the_same_inode(self): + with tempfile.TemporaryDirectory() as tmp: + executable = Path(tmp).resolve() / 'installed-runner' + executable.write_bytes(b'pinned runner') + link = executable.with_name('runtime-runner') + link.symlink_to(executable) + content, inode = fault.checked_runner_executable(str(link)) + self.assertEqual(content, b'pinned runner') + self.assertEqual((content, inode), fault.checked_runner_executable(str(executable))) + + def test_retargeted_link_is_rejected_after_read(self): + with tempfile.TemporaryDirectory() as tmp: + executable = Path(tmp).resolve() / 'installed-runner' + executable.write_bytes(b'pinned runner') + foreign = executable.with_name('foreign-runner') + foreign.write_bytes(b'foreign') + link = executable.with_name('runtime-runner') + link.symlink_to(executable) + original = fault.checked_file + def replace_during_read(*args): + result = original(*args) + link.unlink() + link.symlink_to(foreign) + return result + with patch.object(fault, 'checked_file', side_effect=replace_during_read), self.assertRaisesRegex(RuntimeError, 'runner_link_changed'): + fault.checked_runner_executable(str(link)) + + def test_missing_link_and_directory_never_admit_an_executable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + link = root / 'runtime-runner' + link.symlink_to(root / 'missing') + with self.assertRaises(FileNotFoundError): fault.checked_runner_executable(str(link)) + with self.assertRaisesRegex(RuntimeError, 'runner_file_shape'): fault.checked_runner_executable(str(root)) + + class PidfdTests(unittest.TestCase): def test_changed_or_retired_pidfd_never_signals_and_always_closes(self): before = {'target': TARGET} @@ -186,7 +224,11 @@ class PidfdTests(unittest.TestCase): for descriptor in [3, 7]: with self.subTest(descriptor=descriptor): self.calibrate_real_child(descriptor) - def calibrate_real_child(self, descriptor=None): + @unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Hosted native Linux preinstalled-link calibration required') + def test_real_preinstalled_runner_link_then_exact_child_pidfd(self): + self.calibrate_real_child(7, runner_link=True) + + def calibrate_real_child(self, descriptor=None, runner_link=False): node = shutil.which('node'); self.assertIsNotNone(node) base = Path(tempfile.mkdtemp(prefix='pi-fault-calibration-', dir='/tmp')) snapshot = Path(tempfile.mkdtemp(prefix='paperclip-acpx-native-', dir='/tmp')) @@ -199,7 +241,13 @@ class PidfdTests(unittest.TestCase): for name in NAMES + [fault.GUARD]: p = distribution / name; p.parent.mkdir(parents=True, exist_ok=True); p.write_text('// fixture\n') shutil.copyfile(node, distribution / fault.NODE); (distribution / fault.NODE).chmod(0o500) - shutil.copyfile(node, runtime / 'bin/paperclip-runnerd'); (runtime / 'bin/paperclip-runnerd').chmod(0o500) + runner = runtime / 'bin/paperclip-runnerd' + if runner_link: + installed = base / 'preinstalled-runner' + shutil.copyfile(node, installed); installed.chmod(0o500) + runner.symlink_to(installed) + else: + shutil.copyfile(node, runner); runner.chmod(0o500) ready = workspace / 'child.json'; exited = workspace / 'exit.json' (distribution / fault.ENTRY).write_text('process.title="pi"; require("node:fs").writeFileSync(' + json.dumps(str(ready)) + ',JSON.stringify({pid:process.pid}));setInterval(()=>{},1000);') (distribution / 'pi-entry.cjs').write_text('const p=require("node:child_process").spawn(process.execPath,["--require",' + json.dumps(str(distribution / fault.GUARD)) + ',' + json.dumps(str(distribution / fault.ENTRY)) + '],{stdio:"ignore"});p.on("exit",(code,signal)=>require("node:fs").writeFileSync(' + json.dumps(str(exited)) + ',JSON.stringify({code,signal})));process.on("SIGTERM",()=>{if(p.exitCode!==null||p.signalCode!==null)process.exit(0);p.once("exit",()=>process.exit(0));p.kill("SIGTERM")});setInterval(()=>{},1000);') diff --git a/tests/runner-e2e/pi-provider-fault.test.ts b/tests/runner-e2e/pi-provider-fault.test.ts index 108eec89d6..4c059c2cf5 100644 --- a/tests/runner-e2e/pi-provider-fault.test.ts +++ b/tests/runner-e2e/pi-provider-fault.test.ts @@ -15,8 +15,8 @@ it("calibrates the exact Pi fault helper with metadata negatives and native Linu expect(result.error).toBeUndefined(); expect(result.signal).toBeNull(); expect(result.status, result.stderr).toBe(0); - expect(result.stderr).toContain("Ran 17 tests"); + expect(result.stderr).toContain("Ran 21 tests"); if (process.platform === "linux") expect(result.stderr).not.toContain("skipped"); - else expect(result.stderr).toContain("skipped=2"); + else expect(result.stderr).toContain("skipped=3"); console.info(result.stderr.trim()); }, 30_000);