From e9998cbfdbc84ba9b0d3995670b761a4277b6400 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 2 Sep 2026 20:50:25 -0500 Subject: [PATCH] fix(runner): preserve verified runtime across descendants --- .../crates/runner-core/src/durable/runner.rs | 77 ++++++++++++++++--- .../acpx/verified-runtime-executable.test.ts | 19 ++++- .../acpx/verified-runtime-executable.ts | 16 ++-- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 13c485262d..1e01fae3a2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -290,9 +290,12 @@ pub fn run_durable_runner( .durable_state() .filter(|_| !disconnected) { - executor.shutdown()?; - state.lifecycle = durable_lifecycle.to_owned(); - store.save(&state)?; + persist_lifecycle_before_shutdown( + &mut state, + &store, + &mut executor, + durable_lifecycle, + )?; return Ok(()); } if disconnected { @@ -378,9 +381,12 @@ pub fn run_durable_runner( break; } if let Some(durable_lifecycle) = lifecycle.durable_state() { - executor.shutdown()?; - state.lifecycle = durable_lifecycle.to_owned(); - store.save(&state)?; + persist_lifecycle_before_shutdown( + &mut state, + &store, + &mut executor, + durable_lifecycle, + )?; return Ok(()); } } @@ -396,10 +402,13 @@ pub fn run_durable_runner( "revoke must advance the authenticated revocation epoch", )); } - state.lifecycle = "revoked".to_owned(); state.record_diagnostic("connection capability was revoked"); - executor.shutdown()?; - store.save(&state)?; + persist_lifecycle_before_shutdown( + &mut state, + &store, + &mut executor, + "revoked", + )?; return Ok(()); } Some("ping") => { @@ -431,6 +440,20 @@ pub fn run_durable_runner( } } +fn persist_lifecycle_before_shutdown( + state: &mut DurableState, + store: &DurableStateStore, + executor: &mut E, + lifecycle: &str, +) -> Result<(), DurableRunnerError> { + // A terminal command result is already durable before this boundary. Save + // its matching lifecycle before fallible provider cleanup so recovery can + // never replay a ready runner after the command itself became terminal. + state.lifecycle = lifecycle.to_owned(); + store.save(state)?; + executor.shutdown() +} + fn poll_executor_events( state: &mut DurableState, store: &DurableStateStore, @@ -593,6 +616,8 @@ mod tests { calls: usize, } + struct ShutdownFailingExecutor; + struct RetainingEventExecutor { events: VecDeque, fail_acknowledgement: bool, @@ -614,6 +639,18 @@ mod tests { } } + impl CommandExecutor for ShutdownFailingExecutor { + fn execute(&mut self, _command: &Command) -> Result { + Ok(CommandExecution::result(json!({"status": "completed"}))) + } + + fn shutdown(&mut self) -> Result<(), DurableRunnerError> { + Err(DurableRunnerError::invalid( + "simulated terminal cleanup failure", + )) + } + } + impl CommandExecutor for RetainingEventExecutor { fn execute(&mut self, _command: &Command) -> Result { Ok(CommandExecution::result(json!({"status": "completed"}))) @@ -676,6 +713,28 @@ mod tests { } } + #[test] + fn terminal_lifecycle_is_durable_before_fallible_cleanup() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-terminal-before-cleanup-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = ShutdownFailingExecutor; + + let error = persist_lifecycle_before_shutdown(&mut state, &store, &mut executor, "stopped") + .expect_err("cleanup failure remains observable"); + let (recovered, existed) = store.load_or_create(&config).unwrap(); + + assert!(error.to_string().contains("terminal cleanup failure")); + assert!(existed); + assert_eq!(recovered.lifecycle, "stopped"); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn event_batch_keeps_accepted_prefix_and_unacknowledged_suffix() { let directory = std::env::temp_dir().join(format!( diff --git a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts index 2ffa2e1c5d..a8d9e15509 100644 --- a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.test.ts @@ -6,7 +6,7 @@ import { } from "./verified-runtime-executable.js"; describe("verified runtime executable", () => { - it("anchors an inherited Linux descriptor at its current owner", () => { + it("projects an inherited Linux descriptor through the live process image", () => { expect( verifiedRuntimeExecutable( { [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/fd/17" }, @@ -14,18 +14,29 @@ describe("verified runtime executable", () => { 4321, "/usr/bin/node", ), - ).toBe("/proc/4321/fd/17"); + ).toBe("/proc/self/exe"); }); - it("preserves an already anchored descendant runtime", () => { + it("preserves the live process image for verified descendants", () => { expect( + verifiedRuntimeExecutable( + { [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/exe" }, + "linux", + 8765, + "/usr/bin/node", + ), + ).toBe("/proc/self/exe"); + }); + + it("rejects ancestor descriptor paths at the verified boundary", () => { + expect(() => verifiedRuntimeExecutable( { [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/4321/fd/17" }, "linux", 8765, "/usr/bin/node", ), - ).toBe("/proc/4321/fd/17"); + ).toThrow("descriptor is invalid"); }); it("rejects mutable Linux paths at the verified boundary", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts index 5da0d67e84..1e528c8808 100644 --- a/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts +++ b/packages/paperclip-runner/src/drivers/acpx/verified-runtime-executable.ts @@ -6,25 +6,23 @@ export const VERIFIED_RUNTIME_EXECUTABLE_ENV = /** * Recover the runner-authenticated executable inherited by a descriptor-loaded * sidecar. Linux children cannot use process.execPath here: Node resolves the - * sealed image to a deleted memfd alias. Anchor the descriptor at the current - * owner process before launching a descendant, whose own `/proc/self` would - * otherwise name the wrong descriptor table. + * sealed image to a deleted memfd alias. Once the inherited descriptor has + * authenticated the current image, `/proc/self/exe` keeps that exact live + * image available to every fork/exec generation without granting a descendant + * access to an ancestor's descriptor table. */ export function verifiedRuntimeExecutable( environment: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, - currentPid: number = process.pid, + _currentPid: number = process.pid, fallback: string = process.execPath, ): string { const configured = environment[VERIFIED_RUNTIME_EXECUTABLE_ENV]; if (configured === undefined) return fallback; if (platform === "linux") { - const match = /^\/proc\/self\/fd\/([0-9]+)$/.exec(configured); - if (match) return `/proc/${currentPid}/fd/${match[1]}`; - if (/^\/proc\/[1-9][0-9]*\/fd\/[0-9]+$/.test(configured)) { - return configured; - } + if (/^\/proc\/self\/fd\/[0-9]+$/.test(configured)) return "/proc/self/exe"; + if (configured === "/proc/self/exe") return configured; throw new Error("Verified runtime executable descriptor is invalid"); }