diff --git a/doc/run-log-events.md b/doc/run-log-events.md index b55c9350fa7..3e2a709551e 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -653,3 +653,9 @@ The ordinary authenticated Runner event is the durable record. Normal source-eve acknowledgement and replay deduplicate delivery. Existing run-read authorization and secret redaction apply to saved and live events. Recording reported activity is not independent proof that it happened and grants no resource permission. + +Runner accepts local activity records only with an opaque context +captured before the operation. Warm run attachment and process recovery rotate +that authority. Runner supplies the run identity from its active authenticated +binding. Optional hint failure does not fail the underlying operation. No output +text is scanned and no synthetic receipt is written. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 4ba8686aec5..6988bc56e3d 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -595,6 +595,22 @@ Live console provider-backed routes are loopback-only and reject wildcard/LAN binds. Browser mutations require same-origin Fetch Metadata, matching Origin, and JSON content; see the protocol-server tutorial for direct `curl` examples. +The runtime activity publisher has an offline final-command regression against the +qualified Codex executable. Run it on a host that supports Codex's OS sandbox, +with the pinned executable and the matching freshly built Runner helper: + +```sh +CODEX_NOTIFICATION_SANDBOX_COMMAND=/absolute/path/to/pinned/codex \ +PAPERCLIP_RUNTIME_NOTIFICATION_HELPER=/absolute/path/to/paperclip-runnerd \ +cargo test --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --lib \ + publisher_crosses_real_codex_tool_boundary_without_runner_state_access -- --ignored +``` + +This uses `app-server command/exec` without credentials or a model call. It checks +actual shell discovery, successful durable activity recording, and denial of unrelated +Runner state, descriptor writes and ambient secrets. The normal suite covers +bounded framing, concurrent publishers, fixed-path cleanup and attachment fencing. + ## Direct live protocol qualification The canonical direct live protocol suite lives in the separate diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 2d9ab64d1cb..26d239cd09b 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -5,21 +5,21 @@ "protocolVersion": 1, "profiles": { "grok": { - "agentProfileVersion": 11, + "agentProfileVersion": 12, "agentServerPackage": "builtin:grok-acp", "agentServerVersion": "1", "agentRuntimePackage": "native:grok", "agentRuntimeVersion": "1.0.13", - "commandDigest": "sha256:f43a6f5a583972cdcb422784969cd6a97654655b0f135f1cf178317909c404f8", + "commandDigest": "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a", "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 33, + "agentProfileVersion": 34, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:0260d5810e9247986800ad46c1f9b3511a5abd06cb8f142405d5caacbc3960fc", + "commandDigest": "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f", "requiresProviderPolicy": true }, "cursor": { @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 80, + "agentProfileVersion": 81, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:7b8a6a31925256d225ea3d72fdcfd81b70a45c4b67d3eb0ee5073efff9ac8f8b", + "commandDigest": "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/protocol/README.md b/packages/paperclip-runner/protocol/README.md index b9fdaf0fb35..b5a37e79407 100644 --- a/packages/paperclip-runner/protocol/README.md +++ b/packages/paperclip-runner/protocol/README.md @@ -142,3 +142,11 @@ the run's company, task and agent through its existing authorized run binding; callers cannot supply those identities in the payload. Recording activity does not independently verify the reported action or grant access to any resource. The event uses the ordinary durable outbox, acknowledgement and replay contract. + +Local clients capture an opaque context with `paperclip-runnerd notification-context` +before an operation and invoke `record-activity CONTEXT NAMESPACE ACTIVITY_KEY` +after success. Runner publishes its matching absolute executable as host-owned +`PAPERCLIP_RUNTIME_NOTIFICATION_HELPER` and the stable endpoint descriptor path +as `PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT`. Configured task environment cannot +override either value. Context is invalidated on warm attachment and process +recovery, so a caller cannot attribute a later completion to a different run. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 94c73c304d2..391f6463abf 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3246,7 +3246,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v33-identity.json" + "../../../../test-fixtures/pi-acp/profile-v34-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3263,6 +3263,8 @@ mod tests { current.validate(&context()).unwrap(); for prior in [ + include_str!("../../../../test-fixtures/pi-acp/profile-v33-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v32-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v31-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v29-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v28-identity.json"), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index 938d4618008..55341216d3e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -426,12 +426,36 @@ fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> { reconnect_grace: optional_u64(args, "--reconnect-grace-ms")?.map(Duration::from_millis), max_runtime: duration("--max-runtime-ms", 0)?, }; + #[cfg(unix)] + std::env::set_var( + paperclip_runner_core::runtime_notifications::CONTEXT_ENV, + paperclip_runner_core::runtime_notifications::context_path( + &std::path::absolute(&state_dir) + .map_err(|error| LocalRunnerError::invalid(error.to_string()))?, + ), + ); + #[cfg(unix)] + { + let key = paperclip_runner_core::runtime_notifications::HELPER_ENV; + std::env::remove_var(key); + if let Ok(executable) = std::env::current_exe() { + std::env::set_var(key, executable); + } + } let executor = NativeProviderCommandExecutor::with_runner_config(state_dir, &config); run_durable_runner(config, ticket, executor) .map_err(|error| LocalRunnerError::invalid(error.to_string())) } fn run(args: &[String]) -> Result<(), LocalRunnerError> { + #[cfg(unix)] + if matches!( + args.first().map(String::as_str), + Some("notification-context" | "record-activity") + ) { + return paperclip_runner_core::runtime_notifications::run_cli(args) + .map_err(|error| LocalRunnerError::invalid(error.to_string())); + } if args.first().map(String::as_str) == Some("--stdio-mcp") && (args.len() == 2 || (args.len() == 3 && args[2] == "--content-images")) { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 88a9c40b064..5f16baf89df 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -23,6 +23,52 @@ use crate::provider_events::normalized_codex_terminal_event_type; use crate::qualified_launch::verify_launch_artifact; use crate::question_response::validate_question_response; +#[cfg(unix)] +pub(crate) fn runtime_notification_codex_args( + args: &[String], + profile: &str, + paths: &[PathBuf; 4], +) -> Vec { + let prefix = "shell_environment_policy.include_only="; + let Some(keys) = args.iter().rev().find_map(|arg| arg.strip_prefix(prefix)) else { + return vec![]; + }; + let Ok(mut keys) = serde_json::from_str::>(keys) else { + return vec![]; + }; + for key in [ + crate::runtime_notifications::CONTEXT_ENV, + crate::runtime_notifications::HELPER_ENV, + ] { + if !keys.iter().any(|existing| existing == key) { + keys.push(key.into()); + } + } + let mut extra = vec![ + "-c".into(), + format!("{prefix}{}", serde_json::to_string(&keys).unwrap()), + "-c".into(), + "shell_environment_policy.inherit=\"all\"".into(), + ]; + for profile in BTreeSet::from([profile, "paperclip-runner-workspace-read-only"]) { + let prefix = format!("permissions.{profile}.filesystem="); + let Some(value) = args.iter().rev().find_map(|arg| arg.strip_prefix(&prefix)) else { + continue; + }; + let Ok(mut config) = format!("filesystem={value}").parse::() else { + continue; + }; + let Some(filesystem) = config["filesystem"].as_inline_table_mut() else { + continue; + }; + for (path, mode) in paths.iter().zip(["read", "read", "read", "write"]) { + filesystem.insert(path.to_string_lossy().as_ref(), mode.into()); + } + extra.extend(["-c".into(), format!("{prefix}{filesystem}")]); + } + extra +} + pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; const QUALIFIED_OPENCODE_VERSION: &str = "1.18.34"; const DEFAULT_PROVIDER_TRACE_MAX_BYTES: usize = 64 * 1024 * 1024; @@ -992,6 +1038,18 @@ impl CodexProvider { .map_err(|e| LocalRunnerError::invalid(e.to_string()))?, ); } + #[cfg(unix)] + if config.provider == "codex" { + if let Some(paths) = crate::runtime_notifications::access_paths() { + let extra = + runtime_notification_codex_args(&launch_args, &permission_profile, &paths); + let position = launch_args + .iter() + .position(|arg| arg == "app-server") + .unwrap_or(launch_args.len()); + launch_args.splice(position..position, extra); + } + } let spawn = (|| { if config.provider == "opencode" { let profile = opencode_launch_profile.ok_or_else(|| { @@ -4176,6 +4234,77 @@ fn codex_question_response( mod tests { use super::*; + #[test] + #[cfg(unix)] + fn runtime_notification_policy_grants_only_discovery_files_and_ipc() { + let mut args = vec![ + "shell_environment_policy.include_only=[\"PATH\",\"LANG\",\"PAPERCLIP_API_KEY\"]" + .into(), + ]; + for name in [ + "paperclip-runner-workspace-only", + "paperclip-runner-workspace-read-only", + ] { + args.push(format!( + "permissions.{name}.filesystem={{\":root\"=\"none\",\"/state/private\"=\"none\"}}" + )); + } + let paths = [ + PathBuf::from("/runtime/runnerd"), + PathBuf::from("/state/private/runtime-notifications.json"), + PathBuf::from("/state/private/runtime-activity.lock"), + PathBuf::from("/tmp/prn-channel"), + ]; + let extra = + runtime_notification_codex_args(&args, "paperclip-runner-workspace-only", &paths); + let keys: Vec = serde_json::from_str( + extra[1] + .strip_prefix("shell_environment_policy.include_only=") + .unwrap(), + ) + .unwrap(); + assert_eq!( + keys, + [ + "PATH", + "LANG", + "PAPERCLIP_API_KEY", + crate::runtime_notifications::CONTEXT_ENV, + crate::runtime_notifications::HELPER_ENV + ] + ); + let serialized = extra.join("\n"); + for profile in [ + "paperclip-runner-workspace-only", + "paperclip-runner-workspace-read-only", + ] { + let prefix = format!("permissions.{profile}.filesystem="); + let value = extra + .iter() + .find_map(|arg| arg.strip_prefix(&prefix)) + .unwrap(); + let config = format!("filesystem={value}") + .parse::() + .unwrap(); + let filesystem = config["filesystem"].as_inline_table().unwrap(); + for (path, mode) in [ + (":root", "none"), + ("/state/private", "none"), + ("/runtime/runnerd", "read"), + ("/state/private/runtime-notifications.json", "read"), + ("/state/private/runtime-activity.lock", "read"), + ("/tmp/prn-channel", "write"), + ] { + assert_eq!( + filesystem.get(path).and_then(toml_edit::Value::as_str), + Some(mode) + ); + } + } + assert!(!serialized.contains("network.")); + assert!(!serialized.contains("PAPERCLIP_RUN_ID")); + } + #[cfg(unix)] fn completion_tail_provider() -> CodexProvider { completion_tail_provider_with_stdout_flood(false) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs index 337afa929d5..3f25b976516 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/configured_environment.rs @@ -98,6 +98,8 @@ const RESERVED: &[&str] = &[ "PAPERCLIP_RUNTIME_SERVICES_JSON", "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON", "PAPERCLIP_RUN_ID", + "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", + "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PAPERCLIP_TASK_ID", "PAPERCLIP_WAKE_COMMENT_ID", "PAPERCLIP_WAKE_PAYLOAD_JSON", @@ -199,6 +201,8 @@ mod tests { "[\"NODE_OPTIONS\"]", "[\"PAPERCLIP_API_KEY\"]", "[\"PAPERCLIP_RUNNER_BOOTSTRAP_TICKET\"]", + "[\"PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT\"]", + "[\"PAPERCLIP_RUNTIME_NOTIFICATION_HELPER\"]", "[\"OPENAI_API_KEY\"]", "[\"LD_PRELOAD\"]", "[\"bad=name\"]", 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 ce05d2acce3..2a8eb5f6067 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 @@ -415,6 +415,9 @@ pub fn run_durable_runner( )?; store.save(&state)?; } + #[cfg(unix)] + let mut notifications = + crate::runtime_notifications::RuntimeNotifications::bind(&config.state_dir).ok(); // Bind listener mode or resolve dial mode before processing commands. Dial // reconnects retain the same validated addresses so DNS cannot redirect a // retry after the trust decision. @@ -649,6 +652,10 @@ pub fn run_durable_runner( state.lifecycle = "ready".to_owned(); state.recoverable_failure = None; store.save(&state)?; + #[cfg(unix)] + if let Some(notifications) = notifications.as_mut() { + notifications.poll(&config, &mut state, &store); + } let mut sent_source_seq = state.acked_source_seq; let mut ack_persistence = CumulativeAckPersistence::default(); @@ -819,6 +826,10 @@ pub fn run_durable_runner( "active connection lease expired; durable state is preserved", )); } + #[cfg(unix)] + if let Some(notifications) = notifications.as_mut() { + notifications.poll(&config, &mut state, &store); + } let now = current_unix_ms()?; if welcome.lease_renewal_version == Some(1) && now >= next_lease_renewal { // A failed write may still have reached the controller. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs index 3631e7fc815..62134846862 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs @@ -2647,6 +2647,8 @@ mod tests { struct EventExecutor { session_open_calls: Arc, shutdown_calls: Arc, + #[cfg(unix)] + activity_context_path: std::path::PathBuf, } impl super::super::CommandExecutor for EventExecutor { @@ -2655,6 +2657,14 @@ mod tests { command: &Command, ) -> Result { if command.command_type == "session.open" { + #[cfg(unix)] + { + let endpoint: Value = serde_json::from_slice( + &std::fs::read(&self.activity_context_path).unwrap(), + ) + .unwrap(); + assert!(!endpoint["context"].as_str().unwrap().is_empty(), "welcome commands must see active activity context before starting provider work"); + } let calls = self.session_open_calls.fetch_add(1, Ordering::SeqCst) + 1; return Ok(super::super::CommandExecution { result: json!({"status": "completed", "calls": calls}), @@ -2827,6 +2837,8 @@ mod tests { EventExecutor { session_open_calls: session_open_calls.clone(), shutdown_calls: shutdown_calls.clone(), + #[cfg(unix)] + activity_context_path: crate::runtime_notifications::context_path(&directory), }, ) .unwrap(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 46218f0548b..58a1aecb61d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -20,7 +20,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("native:grok"), agent_runtime_version: Some("1.0.13"), command_digest: - "sha256:f43a6f5a583972cdcb422784969cd6a97654655b0f135f1cf178317909c404f8", + "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a", requires_provider_policy: false, }, "pi" => AcpxReleaseProfile { @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:0260d5810e9247986800ad46c1f9b3511a5abd06cb8f142405d5caacbc3960fc", + "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:7b8a6a31925256d225ea3d72fdcfd81b70a45c4b67d3eb0ee5073efff9ac8f8b", + "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 42c36fc48f9..0e8e9881ae0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -369,3 +369,6 @@ mod tests { ); } } + +#[cfg(unix)] +pub mod runtime_notifications; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs index daab708003d..a0703c45580 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -1034,6 +1034,8 @@ impl SupervisedProcess { .stdout(Stdio::piped()) .stderr(Stdio::piped()); for key in [ + "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", + "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PATH", "PATHEXT", "SystemRoot", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs b/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs new file mode 100644 index 00000000000..897e3231ba2 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs @@ -0,0 +1,784 @@ +//! Run-scoped activity notifications from provider commands. +use crate::durable::{ + create_private_temporary_file, verify_private_directory, DurableRunnerConfig, + DurableRunnerError, DurableState, DurableStateStore, EventPriority, +}; +use crate::process_supervisor::{read_bounded_line, BoundedLine}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufReader, Read, Write}; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender}, + Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +pub const CONTEXT_ENV: &str = "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT"; +pub const HELPER_ENV: &str = "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER"; +const MAX_FRAME: usize = 2048; +const QUEUE_LIMIT: usize = 128; + +pub fn context_path(state_dir: &Path) -> PathBuf { + state_dir.join("runtime-notifications.json") +} +fn failure(error: impl std::fmt::Display) -> DurableRunnerError { + DurableRunnerError::invalid(format!("runtime notification: {error}")) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Endpoint { + pipe: PathBuf, + context: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Activity { + context: String, + namespace: String, + activity_key: String, +} + +// Fixed-path cleanup never traverses provider-created contents. +struct ActivityPipe(PathBuf); +impl Drop for ActivityPipe { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + let _ = fs::remove_file(self.0.with_extension("lock")); + let _ = fs::remove_dir(self.0.parent().unwrap()); + } +} + +fn private_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32) + .open(path) + .map_err(failure)?; + let metadata = file.metadata().map_err(failure)?; + if !metadata.is_file() || metadata.permissions().mode() & 0o077 != 0 || metadata.len() > 8192 { + return Err(failure("invalid endpoint descriptor")); + } + Ok(file) +} + +fn descriptor(path: &Path) -> Result { + let mut file = private_file(path)?; + let mut bytes = Vec::new(); + (&mut file) + .take(8193) + .read_to_end(&mut bytes) + .map_err(failure)?; + serde_json::from_slice(&bytes).map_err(failure) +} + +fn write_descriptor(path: &Path, endpoint: &Endpoint) -> Result<(), DurableRunnerError> { + let bytes = serde_json::to_vec(endpoint).map_err(failure)?; + let (temporary, mut file) = create_private_temporary_file(path)?; + let result = file + .write_all(&bytes) + .and_then(|()| fs::rename(&temporary, path)); + let _ = fs::remove_file(temporary); + result.map_err(failure) +} + +// Check between reads so shutdown also ends an unterminated full-pipe record. +struct StoppableReader<'a, R>(R, &'a AtomicBool); +impl Read for StoppableReader<'_, R> { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + if self.1.load(Ordering::Acquire) { + return Ok(0); + } + self.0.read(bytes) + } +} + +fn receive_records(mut reader: impl Read, sender: SyncSender, stopped: &AtomicBool) { + let mut reader = BufReader::with_capacity(MAX_FRAME, StoppableReader(&mut reader, stopped)); + while !stopped.load(Ordering::Acquire) { + match read_bounded_line(&mut reader, MAX_FRAME - 2) { + Ok(BoundedLine::Line(line)) => { + if let Ok(activity) = serde_json::from_str(&line) { + let _ = sender.try_send(activity); + } + } + Err(error) + if !matches!( + error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::InvalidData + ) => + { + break + } + Ok(BoundedLine::Eof) => break, + _ => {} + } + } +} + +fn publish(path: &Path, activity: &Activity) -> Result<(), DurableRunnerError> { + let bytes = serde_json::to_vec(activity).map_err(failure)?; + if bytes.len() + 2 > MAX_FRAME { + return Err(failure("oversized activity")); + } + let endpoint = descriptor(path)?; + if endpoint.context.is_empty() || endpoint.context != activity.context { + return Err(failure("runtime authority is inactive or changed")); + } + // A stable read-only lock serializes publishers; the descriptor can rotate atomically. + let guard = private_file(&endpoint.pipe.with_extension("lock"))?; + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match guard.try_lock() { + Ok(()) => break, + Err(fs::TryLockError::WouldBlock) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(1)) + } + Err(error) => return Err(failure(error)), + } + } + let mut pipe = OpenOptions::new() + .write(true) + .custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32) + .open(endpoint.pipe) + .map_err(failure)?; + if !pipe.metadata().map_err(failure)?.file_type().is_fifo() { + return Err(failure("invalid activity pipe")); + } + // A leading newline terminates any incomplete previous write. + let mut frame = vec![b'\n']; + frame.extend(bytes); + frame.push(b'\n'); + pipe.write_all(&frame).map_err(failure) +} + +pub struct RuntimeNotifications { + pipe: ActivityPipe, + wake: File, + stopped: Arc, + worker: Option>, + incoming: Receiver, + run_id: Option, + context: String, + recorded: HashSet<(String, String)>, +} + +impl RuntimeNotifications { + pub fn bind(state_dir: &Path) -> Result { + verify_private_directory(state_dir)?; + let directory = fs::canonicalize(std::env::temp_dir()) + .map_err(failure)? + .join(format!("runtime-activity-{}", Uuid::new_v4().simple())); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .map_err(failure)?; + let pipe = ActivityPipe(directory.join("activity.fifo")); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(pipe.0.with_extension("lock")) + .map_err(failure)?; + let status = std::process::Command::new("/usr/bin/mkfifo") + .args(["-m", "600"]) + .arg(&pipe.0) + .env_clear() + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(failure)?; + if !status.success() { + return Err(failure("cannot create activity pipe")); + } + let reader = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32) + .open(&pipe.0) + .map_err(failure)?; + if !reader.metadata().map_err(failure)?.file_type().is_fifo() { + return Err(failure("invalid activity pipe")); + } + let wake = OpenOptions::new() + .write(true) + .custom_flags( + (rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32, + ) + .open(&pipe.0) + .map_err(failure)?; + write_descriptor( + &context_path(state_dir), + &Endpoint { + pipe: pipe.0.clone(), + context: String::new(), + }, + )?; + let (sender, incoming) = mpsc::sync_channel(QUEUE_LIMIT); + let stopped = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&stopped); + let worker = thread::Builder::new() + .name("runner-activity".into()) + .spawn(move || receive_records(reader, sender, &stop)) + .map_err(failure)?; + Ok(Self { + pipe, + wake, + stopped, + worker: Some(worker), + incoming, + run_id: None, + context: String::new(), + recorded: HashSet::new(), + }) + } + + pub fn poll( + &mut self, + config: &DurableRunnerConfig, + state: &mut DurableState, + store: &DurableStateStore, + ) { + let run_id = (state.lifecycle == "ready" + && state.warm_transition.is_none() + && state.run_id == config.run_id) + .then_some(config.run_id.as_str()); + if self.run_id.as_deref() != run_id { + self.run_id = run_id.map(str::to_owned); + self.recorded.clear(); + self.context = if run_id.is_some() { + Uuid::new_v4().to_string() + } else { + String::new() + }; + if write_descriptor( + &context_path(&config.state_dir), + &Endpoint { + pipe: self.pipe.0.clone(), + context: self.context.clone(), + }, + ) + .is_err() + { + self.context.clear(); + } + } + for _ in 0..16 { + let Ok(activity) = self.incoming.try_recv() else { + break; + }; + let _ = self.record(activity, config, state, store); + } + } + + fn record( + &mut self, + activity: Activity, + config: &DurableRunnerConfig, + state: &mut DurableState, + store: &DurableStateStore, + ) -> Result<(), DurableRunnerError> { + let valid = |value: &str| { + !value.is_empty() + && value.chars().count() <= 160 + && !value.chars().any(|ch| ch.is_ascii_control()) + }; + if self.context.is_empty() + || state.run_id != config.run_id + || activity.context != self.context + || !valid(&activity.namespace) + || !valid(&activity.activity_key) + { + return Err(failure("invalid activity context or identifiers")); + } + let key = (activity.namespace, activity.activity_key); + if !self.recorded.contains(&key) { + if self.recorded.len() >= QUEUE_LIMIT { + return Err(failure("activity limit reached")); + } + let mut next = state.clone(); + next.enqueue_event( + config, + "runtime.activity.recorded", + EventPriority::P1, + json!({"namespace":key.0,"activityKey":key.1}), + )?; + store.save(&next)?; + *state = next; + self.recorded.insert(key); + } + Ok(()) + } +} + +impl Drop for RuntimeNotifications { + fn drop(&mut self) { + self.stopped.store(true, Ordering::Release); + // A nonblocking, sub-PIPE_BUF write wakes an idle reader; a full pipe is already readable. + loop { + match self.wake.write(b"\n") { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + _ => break, + } + } + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +pub fn access_paths() -> Option<[PathBuf; 4]> { + let path = PathBuf::from(std::env::var_os(CONTEXT_ENV)?); + let endpoint = descriptor(&path).ok()?; + Some([ + PathBuf::from(std::env::var_os(HELPER_ENV)?), + path, + endpoint.pipe.with_extension("lock"), + endpoint.pipe, + ]) +} + +pub fn run_cli(args: &[String]) -> Result<(), DurableRunnerError> { + let path = PathBuf::from( + std::env::var_os(CONTEXT_ENV).ok_or_else(|| failure("activity endpoint is unavailable"))?, + ); + match args { + [command] if command == "notification-context" => { + let endpoint = descriptor(&path)?; + if endpoint.context.is_empty() { + return Err(failure("runtime authority is inactive")); + } + println!("{}", endpoint.context); + Ok(()) + } + [command, context, namespace, activity_key] if command == "record-activity" => publish( + &path, + &Activity { + context: context.clone(), + namespace: namespace.clone(), + activity_key: activity_key.clone(), + }, + ), + _ => Err(failure("invalid activity arguments")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::os::unix::fs::DirBuilderExt; + struct TestDirectory { + directory: PathBuf, + } + impl TestDirectory { + fn create(parent: &Path) -> std::io::Result { + let directory = parent.join(format!("activity-test-{}", Uuid::new_v4())); + fs::DirBuilder::new().mode(0o700).create(&directory)?; + Ok(Self { directory }) + } + } + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } + } + fn config(path: PathBuf) -> DurableRunnerConfig { + DurableRunnerConfig { + connect_url: "ws://127.0.0.1/runner".into(), + ca_bundle_path: None, + state_dir: path, + runner_instance_id: "runner-1".into(), + environment_lease_id: "lease-1".into(), + run_id: "run-1".into(), + normalized_session_id: "session-1".into(), + turn_id: "turn-1".into(), + item_id: "item-1".into(), + runner_version: "1".into(), + runner_digest: "sha256:digest".into(), + acpx_launch_profile: None, + opencode_launch_profile: None, + max_outbox_bytes: 1024 * 1024, + p0_reserve_bytes: 64 * 1024, + max_frame_bytes: 64 * 1024, + reconnect_delay: Duration::from_millis(1), + reconnect_grace: None, + max_runtime: Duration::ZERO, + } + } + + fn activity(context: &str, key: &str) -> Activity { + Activity { + context: context.into(), + namespace: "example.runtime".into(), + activity_key: key.into(), + } + } + fn setup() -> ( + TestDirectory, + DurableRunnerConfig, + DurableStateStore, + DurableState, + RuntimeNotifications, + ) { + let directory = TestDirectory::create(&std::env::temp_dir()).unwrap(); + let config = config(directory.directory.clone()); + let store = DurableStateStore::new(&config.state_dir).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + state.lifecycle = "ready".into(); + let mut server = RuntimeNotifications::bind(&config.state_dir).unwrap(); + server.poll(&config, &mut state, &store); + (directory, config, store, state, server) + } + #[test] + fn capture_is_local_and_authority_rotation_rejects_late_completion() { + let (_directory, mut config, store, mut state, mut server) = setup(); + let first = descriptor(&context_path(&config.state_dir)).unwrap(); + for _ in 0..100 { + assert_eq!( + descriptor(&context_path(&config.state_dir)) + .unwrap() + .context, + first.context + ); + } + config.run_id = "next-run".into(); + state.run_id.clone_from(&config.run_id); + server.poll(&config, &mut state, &store); + assert!(server + .record( + activity(&first.context, "desktop.used"), + &config, + &mut state, + &store + ) + .is_err()); + let current = server.context.clone(); + server + .record( + activity(¤t, "desktop.used"), + &config, + &mut state, + &store, + ) + .unwrap(); + server + .record( + activity(¤t, "desktop.used"), + &config, + &mut state, + &store, + ) + .unwrap(); + assert_eq!(state.outbox.len(), 1); + assert_eq!(state.outbox[0].envelope["payload"]["runId"], "next-run"); + state.lifecycle = "closed".into(); + server.poll(&config, &mut state, &store); + assert!(server + .record( + activity(¤t, "desktop.used"), + &config, + &mut state, + &store + ) + .is_err()); + assert!(descriptor(&context_path(&config.state_dir)) + .unwrap() + .context + .is_empty()); + drop(server); + state.lifecycle = "ready".into(); + let mut recovered = RuntimeNotifications::bind(&config.state_dir).unwrap(); + recovered.poll(&config, &mut state, &store); + assert!(recovered + .record( + activity(¤t, "desktop.used"), + &config, + &mut state, + &store + ) + .is_err()); + } + #[test] + fn unicode_limits_and_failed_persistence_leave_authority_usable() { + let (_directory, config, store, mut state, mut server) = setup(); + let context = server.context.clone(); + for key in ["视".repeat(60), "😀".repeat(160), "\u{85}".into()] { + server + .record(activity(&context, &key), &config, &mut state, &store) + .unwrap(); + } + for key in [ + "".into(), + "😀".repeat(161), + "\u{1f}".into(), + "\u{7f}".into(), + ] { + assert!(server + .record(activity(&context, &key), &config, &mut state, &store) + .is_err()); + } + let saved = config.state_dir.join("runner-state.json"); + let backup = saved.with_extension("backup"); + fs::rename(&saved, &backup).unwrap(); + fs::create_dir(&saved).unwrap(); + let before = state.outbox.len(); + assert!(server + .record( + activity(&context, "retry.used"), + &config, + &mut state, + &store + ) + .is_err()); + assert_eq!(state.outbox.len(), before); + fs::remove_dir(&saved).unwrap(); + fs::rename(backup, saved).unwrap(); + server + .record( + activity(&context, "retry.used"), + &config, + &mut state, + &store, + ) + .unwrap(); + assert_eq!(state.outbox.len(), before + 1); + } + #[test] + fn malformed_partial_and_oversized_records_resynchronize_with_bounded_queue() { + let good = serde_json::to_vec(&activity("context", "desktop.used")).unwrap(); + let mut bytes = vec![b'x'; MAX_FRAME * 2]; + bytes.extend(b"\n{broken\n\xff\n{\"partial\":"); + for _ in 0..QUEUE_LIMIT + 10 { + bytes.push(b'\n'); + bytes.extend(&good); + bytes.push(b'\n'); + } + let (tx, rx) = mpsc::sync_channel(QUEUE_LIMIT); + receive_records(Cursor::new(bytes), tx, &AtomicBool::new(false)); + let records: Vec<_> = rx.try_iter().collect(); + assert_eq!(records.len(), QUEUE_LIMIT); + assert_eq!(records[0].activity_key, "desktop.used"); + assert!(serde_json::from_value::(json!({"context":"context","namespace":"example.runtime","activityKey":"desktop.used","runId":"caller"})).is_err()); + } + #[test] + fn publish_reaches_the_durable_run_without_a_reply_or_idle_filesystem_work() { + let (_directory, config, store, mut state, mut server) = setup(); + let path = context_path(&config.state_dir); + let modified = fs::metadata(&path).unwrap().modified().unwrap(); + let context = server.context.clone(); + publish(&path, &activity(&context, "desktop.used")).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while state.outbox.is_empty() && Instant::now() < deadline { + server.poll(&config, &mut state, &store); + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(state.outbox.len(), 1); + for _ in 0..100 { + server.poll(&config, &mut state, &store); + } + assert_eq!(fs::metadata(path).unwrap().modified().unwrap(), modified); + assert_eq!( + state.outbox[0].envelope["payload"]["payload"], + json!({"namespace":"example.runtime","activityKey":"desktop.used"}) + ); + } + #[test] + fn concurrent_unicode_records_stay_framed_and_rotation_does_not_wait_for_publishers() { + let (_directory, mut config, store, mut state, mut server) = setup(); + let path = context_path(&config.state_dir); + let old = descriptor(&path).unwrap(); + let guard = private_file(&old.pipe.with_extension("lock")).unwrap(); + guard.try_lock().unwrap(); + config.run_id = "next-run".into(); + state.run_id.clone_from(&config.run_id); + server.poll(&config, &mut state, &store); + let current = descriptor(&path).unwrap(); + assert_ne!(old.context, current.context); + assert!(publish(&path, &activity(&old.context, "desktop.used")).is_err()); + assert!(publish(&path, &activity(¤t.context, "desktop.used")).is_err()); + drop(guard); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let writers: Vec<_> = (0..8) + .map(|index| { + let path = path.clone(); + let barrier = Arc::clone(&barrier); + let activity = Activity { + context: current.context.clone(), + namespace: "😀".repeat(160), + activity_key: format!("{index}{}", "😀".repeat(159)), + }; + thread::spawn(move || { + barrier.wait(); + publish(&path, &activity).unwrap(); + }) + }) + .collect(); + for writer in writers { + writer.join().unwrap(); + } + let deadline = Instant::now() + Duration::from_secs(2); + while state.outbox.len() < 8 && Instant::now() < deadline { + server.poll(&config, &mut state, &store); + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(state.outbox.len(), 8); + assert!(state + .outbox + .iter() + .all(|event| event.envelope["payload"]["runId"] == "next-run")); + } + + #[test] + fn endpoint_replacement_cannot_redirect_writes_or_make_shutdown_traverse_a_tree() { + let (_directory, config, store, mut state, mut server) = setup(); + let pipe = server.pipe.0.clone(); + fs::remove_file(&pipe).unwrap(); + fs::create_dir(&pipe).unwrap(); + let mut nested = pipe.clone(); + for _ in 0..100 { + nested = nested.join("deep"); + fs::create_dir(&nested).unwrap(); + } + fs::write(nested.join("protected"), b"unchanged").unwrap(); + fs::set_permissions(&nested, fs::Permissions::from_mode(0)).unwrap(); + assert!(publish( + &context_path(&config.state_dir), + &activity(&server.context, "desktop.used") + ) + .is_err()); + for _ in 0..100 { + server.poll(&config, &mut state, &store); + } + server.wake.write_all(b"{\"partial\":").unwrap(); + let started = Instant::now(); + drop(server); + assert!(started.elapsed() < Duration::from_secs(2)); + fs::set_permissions(&nested, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(fs::read(nested.join("protected")).unwrap(), b"unchanged"); + fs::remove_dir_all(pipe.parent().unwrap()).unwrap(); + } + #[test] + #[ignore = "requires CODEX_NOTIFICATION_SANDBOX_COMMAND and PAPERCLIP_RUNTIME_NOTIFICATION_HELPER"] + fn publisher_crosses_real_codex_tool_boundary_without_runner_state_access() { + use serde_json::Value; + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + use std::sync::mpsc; + let command = std::env::var_os("CODEX_NOTIFICATION_SANDBOX_COMMAND").unwrap(); + let helper = std::env::var_os(HELPER_ENV).unwrap(); + let directory = TestDirectory::create(&std::env::temp_dir()).unwrap(); + let config = config(directory.directory.clone()); + let store = DurableStateStore::new(&config.state_dir).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + state.lifecycle = "ready".into(); + let mut server = RuntimeNotifications::bind(&config.state_dir).unwrap(); + server.poll(&config, &mut state, &store); + let paths = [ + PathBuf::from(&helper), + context_path(&config.state_dir), + server.pipe.0.with_extension("lock"), + server.pipe.0.clone(), + ]; + let workspace = config.state_dir.join("workspace"); + fs::create_dir(&workspace).unwrap(); + let home = workspace.join("home"); + fs::create_dir(&home).unwrap(); + let profile = "paperclip-runner-workspace-only"; + let mut args = vec![ + "-c".into(), + format!("default_permissions=\"{profile}\""), + "-c".into(), + "shell_environment_policy.include_only=[\"PATH\",\"LANG\"]".into(), + ]; + for name in [profile, "paperclip-runner-workspace-read-only"] { + args.extend(["-c".into(), format!("permissions.{name}.filesystem={{\":root\"=\"none\",\":minimal\"=\"read\",\":tmpdir\"=\"none\",\":workspace_roots\"={{\".\"=\"read\"}}}}"), + "-c".into(), format!("permissions.{name}.network.enabled=false")]); + } + let extra = crate::codex_provider::runtime_notification_codex_args(&args, profile, &paths); + let script = r#" +[ -z "${UNRELATED_SECRET:-}" ] || exit 70 +if cat "$1" >/dev/null 2>&1; then exit 71; fi +if /bin/sh -c ': >> "$PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT"' 2>/dev/null; then exit 72; fi +if touch "$2/probe" 2>/dev/null; then exit 75; fi +if touch "$3/probe" 2>/dev/null; then exit 76; fi +if /bin/sh -c ': >> "$1"' ignored "$4" 2>/dev/null; then exit 77; fi +context=$("$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" notification-context) || exit 73 +"$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" record-activity "$context" example.runtime desktop.used || exit 74 +"#; + let mut child = Command::new(command) + .args(args) + .args(extra) + .args(["app-server", "--stdio", "--strict-config"]) + .current_dir(&workspace) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("LANG", "C.UTF-8") + .env("HOME", &home) + .env("CODEX_HOME", &home) + .env("UNRELATED_SECRET", "must-not-reach-command") + .env(CONTEXT_ENV, &paths[1]) + .env(HELPER_ENV, helper) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let output = child.stdout.take().unwrap(); + let (tx, rx) = mpsc::channel(); + let reader = thread::spawn(move || { + for line in BufReader::new(output).lines().map_while(Result::ok) { + if let Ok(value) = serde_json::from_str::(&line) { + let _ = tx.send(value); + } + } + }); + writeln!(input, "{}", json!({"id":1,"method":"initialize","params":{"clientInfo":{"name":"notification-probe","version":"1"},"capabilities":{"experimentalApi":true}}})).unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let mut response = None; + while child.try_wait().unwrap().is_none() && Instant::now() < deadline && response.is_none() + { + server.poll(&config, &mut state, &store); + while let Ok(message) = rx.try_recv() { + if message["id"] == 1 { + assert!(message.get("error").is_none(), "initialize: {message}"); + writeln!(input, "{}", json!({"method":"initialized","params":{}})).unwrap(); + writeln!(input, "{}", json!({"id":2,"method":"command/exec","params":{ + "command":["/bin/sh","-c",script,"publisher",config.state_dir.join("runner-state.json"),config.state_dir,server.pipe.0.parent().unwrap(),server.pipe.0.with_extension("lock")], + "cwd":workspace,"timeoutMs":10000}})).unwrap(); + } else if message["id"] == 2 { + response = Some(message); + } + } + thread::sleep(Duration::from_millis(1)); + } + if child.try_wait().unwrap().is_none() { + child.kill().unwrap(); + } + let result = child.wait_with_output().unwrap(); + reader.join().unwrap(); + let response = response.unwrap_or_else(|| { + panic!( + "app-server did not reply: {}", + String::from_utf8_lossy(&result.stderr) + ) + }); + assert!(response.get("error").is_none(), "command: {response}"); + assert_eq!(response["result"]["exitCode"], 0, "publisher: {response}"); + let deadline = Instant::now() + Duration::from_secs(2); + while state.outbox.is_empty() && Instant::now() < deadline { + server.poll(&config, &mut state, &store); + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(state.outbox.len(), 1); + assert_eq!(state.outbox[0].envelope["payload"]["runId"], config.run_id); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index b9b58e8e9be..16bd6ba02c5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc"; const PI_ACPX_DIGEST: &str = - "sha256:0260d5810e9247986800ad46c1f9b3511a5abd06cb8f142405d5caacbc3960fc"; + "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs new file mode 100644 index 00000000000..ae2a7d2e78e --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs @@ -0,0 +1,123 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::thread; +use std::time::{Duration, Instant}; + +use paperclip_runner_core::durable::{DurableRunnerConfig, DurableStateStore}; +use paperclip_runner_core::runtime_notifications::{ + context_path, RuntimeNotifications, CONTEXT_ENV, HELPER_ENV, +}; + +fn helper(config: &DurableRunnerConfig, args: &[&str]) -> Output { + Command::new(std::env::var_os(HELPER_ENV).expect("Runner publishes its matching helper")) + .env("PATH", "/usr/bin:/bin") + .env( + CONTEXT_ENV, + fs::canonicalize(context_path(&config.state_dir)).unwrap(), + ) + .env("PAPERCLIP_RUN_ID", "stale-process-run") + .current_dir(std::env::temp_dir()) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .unwrap() +} + +#[test] +fn real_cli_uses_relative_state_from_another_directory_and_never_process_run_identity() { + let path = std::env::current_dir() + .unwrap() + .join(format!("notification-cli-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + let mut config = DurableRunnerConfig { + connect_url: "ws://127.0.0.1/runner".into(), + ca_bundle_path: None, + state_dir: PathBuf::from(path.file_name().unwrap()), + runner_instance_id: "runner".into(), + environment_lease_id: "lease".into(), + run_id: "active-run".into(), + normalized_session_id: "session".into(), + turn_id: "turn".into(), + item_id: "item".into(), + runner_version: "1".into(), + runner_digest: "sha256:digest".into(), + acpx_launch_profile: None, + opencode_launch_profile: None, + max_outbox_bytes: 1024 * 1024, + p0_reserve_bytes: 65536, + max_frame_bytes: 65536, + reconnect_delay: Duration::from_millis(1), + reconnect_grace: None, + max_runtime: Duration::ZERO, + }; + let store = DurableStateStore::new(&path).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + state.lifecycle = "ready".into(); + let mut server = RuntimeNotifications::bind(&config.state_dir).unwrap(); + server.poll(&config, &mut state, &store); + std::env::set_var(CONTEXT_ENV, fs::canonicalize(context_path(&path)).unwrap()); + std::env::set_var(HELPER_ENV, env!("CARGO_BIN_EXE_paperclip-runnerd")); + let mut provider = paperclip_runner_core::process_supervisor::SupervisedProcess::spawn( + std::path::Path::new("/bin/sh"), + &[ + "-c".into(), + "printf '%s\\n' \"$PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT\" \"$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER\"".into(), + ], + Duration::from_millis(100), + 2048, + ) + .unwrap(); + assert_eq!( + provider + .receive_stdout_line(Duration::from_secs(1)) + .unwrap(), + Some( + fs::canonicalize(context_path(&path)) + .unwrap() + .to_string_lossy() + .into_owned() + ) + ); + assert_eq!( + provider + .receive_stdout_line(Duration::from_secs(1)) + .unwrap(), + Some(env!("CARGO_BIN_EXE_paperclip-runnerd").to_owned()) + ); + provider.wait().unwrap(); + let captured = helper(&config, &["notification-context"]); + assert!(captured.status.success(), "{:?}", captured); + let context = String::from_utf8(captured.stdout).unwrap(); + let args = [ + "record-activity", + context.trim(), + "example.runtime", + "desktop.used", + ]; + let activated = helper(&config, &args); + assert!(activated.status.success(), "{:?}", activated); + assert!(activated.stdout.is_empty()); + assert!(activated.stderr.is_empty()); + let deadline = Instant::now() + Duration::from_secs(2); + while state.outbox.is_empty() && Instant::now() < deadline { + server.poll(&config, &mut state, &store); + thread::sleep(Duration::from_millis(1)); + } + assert_eq!(state.outbox[0].envelope["payload"]["runId"], "active-run"); + config.run_id = "next-run".into(); + state.run_id.clone_from(&config.run_id); + server.poll(&config, &mut state, &store); + let stale = helper(&config, &args); + assert!(!stale.status.success()); + assert_eq!(state.outbox.len(), 1); + std::env::remove_var(CONTEXT_ENV); + std::env::remove_var(HELPER_ENV); + drop(server); + fs::remove_dir_all(path).unwrap(); +} diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index 98a99aaf120..e72fba2aa18 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v80-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v81-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 80 }; +const declaration = { ...historical.declaration, agentProfileVersion: 81 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/scripts/grok-profile-identity.mjs b/packages/paperclip-runner/scripts/grok-profile-identity.mjs index e0210feb2e2..3ea0bb086f6 100644 --- a/packages/paperclip-runner/scripts/grok-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/grok-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(packageRoot, "../.."); -// Versions 1–10 remain decodable historical identities. Versioned fixtures are +// Versions 1–11 remain decodable historical identities. Versioned fixtures are // immutable; advance this version before changing a versioned declaration. -export const GROK_PROFILE_VERSION = 11; +export const GROK_PROFILE_VERSION = 12; // Contract surfaces: ACPX wire patch; sidecar parsing and error translation; // native effort/search/credential admission; host/sandbox/tool-bridge execution; // launcher integrity and platform pins; Rust descriptor writes, session state, diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index eaf4f904105..83d02cb7664 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -660,6 +660,9 @@ describe("native backend factory", () => { ["copilot", "../../test/fixtures/copilot-profile-v75-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v76-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v77-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v78-identity.json"], + ["pi", "../../test-fixtures/pi-acp/profile-v32-identity.json"], + ["grok", "../../test/fixtures/grok-profile-v10-identity.json"], ["cursor", "../../test/fixtures/cursor-acp/profile-v10-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v14-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v60-identity.json"], diff --git a/packages/paperclip-runner/src/configured-environment.ts b/packages/paperclip-runner/src/configured-environment.ts index 3cca642989f..487e375cdaf 100644 --- a/packages/paperclip-runner/src/configured-environment.ts +++ b/packages/paperclip-runner/src/configured-environment.ts @@ -10,7 +10,7 @@ export const GENERATED_RUNTIME_ENVIRONMENT_KEYS: ReadonlySet = new Set([ ...AGENT_OBSERVABILITY_ENV_KEYS, "PAPERCLIP_AGENT_ID", "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PAPERCLIP_API_KEY", "PAPERCLIP_API_URL", "PAPERCLIP_API_BRIDGE_MODE", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_INSTANCE_ID", - "PAPERCLIP_RUN_ID", "PAPERCLIP_NORMALIZED_SESSION_ID", "PAPERCLIP_RUNNER_INSTANCE_ID", "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET", "PAPERCLIP_TASK_ID", "PAPERCLIP_APPROVAL_ID", "PAPERCLIP_APPROVAL_STATUS", "PAPERCLIP_LINKED_ISSUE_IDS", + "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", "PAPERCLIP_RUN_ID", "PAPERCLIP_NORMALIZED_SESSION_ID", "PAPERCLIP_RUNNER_INSTANCE_ID", "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET", "PAPERCLIP_TASK_ID", "PAPERCLIP_APPROVAL_ID", "PAPERCLIP_APPROVAL_STATUS", "PAPERCLIP_LINKED_ISSUE_IDS", "PAPERCLIP_WAKE_REASON", "PAPERCLIP_WAKE_COMMENT_ID", "PAPERCLIP_WAKE_PAYLOAD_JSON", "PAPERCLIP_EXECUTION_MODE", "PAPERCLIP_WORKSPACES_JSON", "PAPERCLIP_WORKSPACE_ID", "PAPERCLIP_WORKSPACE_CWD", "PAPERCLIP_WORKSPACE_SOURCE", "PAPERCLIP_WORKSPACE_REPO_URL", "PAPERCLIP_WORKSPACE_REPO_REF", "PAPERCLIP_WORKSPACE_BRANCH", diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 92b05070d37..0acaed242c9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,9 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(80); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(81); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:7b8a6a31925256d225ea3d72fdcfd81b70a45c4b67d3eb0ee5073efff9ac8f8b"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c"); }); it("preserves published v65 while binding retained-maintenance waits in v66", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index 682777ad049..2d1615a651e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -22,6 +22,12 @@ describe("ACPX launch environment", () => { expect(launch.ACPX_CLAUDE_INCLUDE_USER_SETTINGS).toBeUndefined(); expect(environment.CLAUDE_CONFIG_DIR).toBe(configured); }); + it("retains Runner notification discovery while denying task-supplied overrides", () => { + for (const key of ["PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER"]) { + expect(createSanitizedAcpxSpawnInput({ [key]: "/private/runner/context" }, "claude").env[key]).toBe("/private/runner/context"); + expect(configuredEnvironmentProjection({ [key]: "/caller/override" })).not.toHaveProperty(key); + } + }); it("retains an explicit Claude MCP timeout without inheriting an ambient or sibling setting", () => { vi.stubEnv("MCP_TOOL_TIMEOUT", "999999"); expect(createSanitizedAcpxSpawnInput(undefined, "claude").env).not.toHaveProperty("MCP_TOOL_TIMEOUT"); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index f1c34360df1..8f393b4b599 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -129,6 +129,8 @@ export function createSanitizedAcpxSpawnInput( "all_proxy", "RUST_BACKTRACE", "PAPERCLIP_NATIVE_MCP_NAME", + "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", + "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PAPERCLIP_NATIVE_MCP_URL", ...(environment === undefined ? [] : ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]), ...credentialNames, diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 60a667c3f5a..9601bea35f3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -9,12 +9,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "grok", - "agentProfileVersion": 11, + "agentProfileVersion": 12, "agentServerPackage": "builtin:grok-acp", "agentServerVersion": "1", "agentRuntimePackage": "native:grok", "agentRuntimeVersion": "1.0.13", - "commandDigest": "sha256:f43a6f5a583972cdcb422784969cd6a97654655b0f135f1cf178317909c404f8", + "commandDigest": "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a", "permissionPolicy": "interactive" }, "pi": { @@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "pi", - "agentProfileVersion": 33, + "agentProfileVersion": 34, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:0260d5810e9247986800ad46c1f9b3511a5abd06cb8f142405d5caacbc3960fc", + "commandDigest": "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f", "permissionPolicy": "interactive" }, "cursor": { @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 80, + "agentProfileVersion": 81, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:7b8a6a31925256d225ea3d72fdcfd81b70a45c4b67d3eb0ee5073efff9ac8f8b", + "commandDigest": "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts index 58921588738..e5aa088a3ee 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-installation.test.ts @@ -175,6 +175,14 @@ describe("Pi installation factory", () => { expect(isSupportedAcpxProfileVersion("pi", 29)).toBe(true); }); + it("preserves profile 33 while binding host-owned notification discovery in 34", async () => { + const prior = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v33-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v34-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "credentialEnvironmentSourceSha256"]); + expect(isSupportedAcpxProfileVersion("pi", 33)).toBe(true); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` @@ -210,21 +218,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 33"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 33"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 33"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 34"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 34"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 34"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 21eaeedd6d6..05954b8f78c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { - pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79], + pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], + grok: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 60ff8eb5fec..afc57a4e00f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -31,6 +31,13 @@ describe("ACPX recovery identity", () => { ["copilot", "../../../test/fixtures/copilot-profile-v75-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v76-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v77-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v78-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v79-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v80-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v32-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v33-identity.json"], + ["grok", "../../../test/fixtures/grok-profile-v10-identity.json"], + ["grok", "../../../test/fixtures/grok-profile-v11-identity.json"], ["cursor", "../../../test/fixtures/cursor-acp/profile-v10-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v14-identity.json"], ["pi", "../../../test-fixtures/pi-acp/profile-v13-identity.json"], diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 6862bccfc39..f89dc89059c 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -894,6 +894,8 @@ describe("OpenCodeServerDriver", () => { environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "test-openrouter-key", + PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT: join(root, "runtime-notifications.json"), + PAPERCLIP_RUNTIME_NOTIFICATION_HELPER: join(root, "runnerd"), PAPERCLIP_API_KEY: "must-not-leak", UNRELATED_SECRET: "must-not-leak", PAPERCLIP_PROVIDER_TRACE_PATH: tracePath, @@ -1037,6 +1039,8 @@ describe("OpenCodeServerDriver", () => { "utf8", ), ); + expect(environment.notificationContext).toBe(join(root, "runtime-notifications.json")); + expect(environment.notificationHelper).toBe(join(root, "runnerd")); expect(environment.keys).not.toContain("OPENROUTER_API_KEY"); expect(environment.keys).not.toContain("PAPERCLIP_API_KEY"); expect(environment.keys).not.toContain("UNRELATED_SECRET"); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 2694d75fdf9..45fb78ccefc 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2967,6 +2967,7 @@ function sanitizedEnvironment( overrides: Record, ): NodeJS.ProcessEnv { const allowed = [ + "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", @@ -2989,6 +2990,7 @@ function sanitizedEnvironment( function sanitizedEnvironmentKeys(): string[] { return [ + "PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT", "PAPERCLIP_RUNTIME_NOTIFICATION_HELPER", "PATH", "LANG", "LC_ALL", diff --git a/packages/paperclip-runner/src/providers/grok/launcher.cjs b/packages/paperclip-runner/src/providers/grok/launcher.cjs index ec47bb32d29..6f1f9d7b095 100755 --- a/packages/paperclip-runner/src/providers/grok/launcher.cjs +++ b/packages/paperclip-runner/src/providers/grok/launcher.cjs @@ -1,4 +1,4 @@ -// Grok admission source identity: sha256:c5a4628adcdd9190c45558f42014b0055698b8f8c7eb301417238f38d913f503 +// Grok admission source identity: sha256:79188d089f3f6b575726af77fe055a58449144a373a0745dcad4be414cba8cd0 // This launcher is admitted by digest and receives a verified native executable. // Never resolve an executable through PATH or accept command-line overrides. const { isAbsolute, join } = require("node:path"); diff --git a/packages/paperclip-runner/test-fixtures/pi-acp/profile-v34-identity.json b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v34-identity.json new file mode 100644 index 00000000000..717f614ee9b --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/pi-acp/profile-v34-identity.json @@ -0,0 +1,52 @@ +{ + "commandDigest": "sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "pi", + "agentProfileVersion": 34, + "acpxVersion": "0.13.1", + "agentServerVersion": "0.0.33", + "agentRuntimeVersion": "1.0.0", + "nodeVersion": "24.21.0", + "closure": { + "darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf", + "darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e", + "linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410" + }, + "wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36", + "helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d", + "extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4", + "agentFilesBinding": "registered-runtime-context-v1", + "nativeQuestions": "select-confirm-input-editor-v4-root-field-types", + "pathPolicy": "sdk-normalization-v1", + "mcpToolNames": "provider-safe-exact-source-v1", + "acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2", + "nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts", + "nativeNotices": "session-extension-v2-bounded-runtime-failure", + "messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd", + "noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "nativeInputDisposition": "queued-only-v1", + "cacheWarming": "disabled-owned-decision-v1", + "thinkingLevel": { + "supported": [ + "off", + "low", + "high", + "max" + ], + "newConfigurationDefault": "low", + "admission": "explicit-native-effective-before-prompt-v1", + "recovery": "mode-bound-v1" + }, + "modelSelection": "caller-selected-native-verified-v1", + "providerConfigurationSourceSha256": "75a9a13f13f224dc092bd8ebf49e8747162172943761e44476c8eb18d80c52d4", + "credentialEnvironmentSourceSha256": "fbc9fc037dab8134d090ac7c3eb23f80026cd84085d56a9704fcf9ceb0478926", + "runtimeSandboxSourceSha256": "04a49a24e3828ce8fca908de15e31ae932b8b1b4f7ecec0308627f6a8da9ba20", + "recoveryIdentitySourceSha256": "f74d061cc07b0c7ca04df62ca991d940a733d9ef6594b3362451769fc10030b8", + "nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1", + "dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814", + "openRouterAttributionSourceSha256": "1211191529d6073b733b311c3d69cada4973eede0f3e540c46e261f78e0b90f9", + "preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e" + } +} diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v81-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v81-identity.json new file mode 100644 index 00000000000..964803b212e --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v81-identity.json @@ -0,0 +1,88 @@ +{ + "commandDigest": "sha256:a51bdf77bba778c4d0265bbfd92a0e6c146f9de5debc3b4a2a268c34e5576e0c", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 81, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8764b8c9032fdc7d1415b01e75a9fc57c550e7a5e11da4bed7d9100082872ae5", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "7d6e948b7cff4df52a9afc916db46ace0f42b4b2d3134ba0c21ee0a928857e62", + "semanticDirectDriverSourceSha256": "d683429427dd8a1a5efe50952018ea486f808bdbf0a92fc5946ee267fe2c72ee", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "f49398b682a5623812f5d3c33d6bb9a749008e47cc0ed969072041615f66df59", + "semanticValidatorsSourceSha256": "b20ec8ee6e7165117233ce4421a975bb9d3f276220ce31655c0b9a717cb2d9cb", + "semanticSchemaBundleSourceSha256": "81ef98ca7b34896a0f76390ac7af3dd8ef7609fe6f38d54bd6a69373d50fe40b", + "semanticSidecarProtocolSourceSha256": "26dd0cfbef8b2b401de6b7862a4af7f243fb703e23678a1a5ed8fa68d4784d42", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "ecef0ada320a762e0a3d91c38c72d4e19e2c8e8be8c060938a47e7f1a029058a", + "configuredEnvironmentRustSourceSha256": "054ce778765690c35a4bbb4e73d923d7dd181226865b7a2e76e7952b4a0f44a3", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "3e61c4cdd4b78e38fcb16eaadae4f1fd65ce3c7127184ea7a80948c8e5d77f88", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "1fe6fa9e6b96d84d70432b85a57c473f833606d4a521563d5022b4d3c41576ce", + "nativeRuntimeContextSourceSha256": "71607f80dbb8dc5cc6c123d0b8f3995d6c5407277695a8c82cbedc90fb8f4842", + "nativeRuntimePromptsSourceSha256": "fff5755dba9b7dce8a5f10ff6ac6d5ed06b92ed079ce1e0a44ac72f2ce7869c1", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "baba1a210b863518f01e0c875606ed37f9467c433222251438578bd999bc43b5", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "03fca93d36a89b0cc0917335e293ad097fdf04356d21b307827bb0ea4c13f523", + "nativeBackendSourceSha256": "7495d9898d22dca1ebf6336361d332bafefc4498f6e49f1135025fad846c8586", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "82ac6e5f7275b4ef79fb166c7330efc504e80cab3d7e9081e4e40a8b570bc50b", + "providerLaunchSourceSha256": "c744235b3067ea75973523f78f1e13399337bc155f1775af694e34782d5c9bef", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "2d167741defe23d6b2b0647a54405331ae4457d298936f0796733628ad79d7bc", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "56a177d3e702914818dca4ca50e061f0d25f988bfc4c0582c36ce23c1696d82f", + "nativeExecutionSourceSha256": "d60b8ea63bad975c0144f0702b2749d7db9a7da50680a6147f20d4d11f2d5297", + "profileCompatibilitySourceSha256": "a9fca95a16c45b92fbe7d7f19d9a5b97602530580901afdb46ec3999729692b0", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "fbc9fc037dab8134d090ac7c3eb23f80026cd84085d56a9704fcf9ceb0478926", + "admissionDiagnosticsSourceSha256": "df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af", + "grokAdmissionSourceSha256": "f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777", + "sidecarErrorSourceSha256": "17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "c9cc0fd516b3df92a6655f56ee9bef37615a889c371e2e137d8ac5c1176de811", + "preparedHarnessHomeSourceSha256": "bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e", + "runtimeHostSourceSha256": "13f9ac382a279a67adb8974c36bbed38fe43d9b93b87bd7a05e879ead7f03d63", + "runtimeAdapterSourceSha256": "7c47c05306ebe7d1ca6b5d046a96f5726a48adec4d27e7c16788f505f25b5db2", + "runtimeSandboxSourceSha256": "04a49a24e3828ce8fca908de15e31ae932b8b1b4f7ecec0308627f6a8da9ba20", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index cde9d5fbc87..b08bfd226c8 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -101,6 +101,8 @@ await writeFile( join(process.env.XDG_DATA_HOME, "fake-environment.json"), JSON.stringify({ keys: Object.keys(process.env).sort(), + notificationContext: process.env.PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT, + notificationHelper: process.env.PAPERCLIP_RUNTIME_NOTIFICATION_HELPER, noProxy: process.env.NO_PROXY, home: process.env.HOME, configHome: process.env.XDG_CONFIG_HOME, diff --git a/packages/paperclip-runner/test/fixtures/grok-profile-v12-identity.json b/packages/paperclip-runner/test/fixtures/grok-profile-v12-identity.json new file mode 100644 index 00000000000..0292406d0b6 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/grok-profile-v12-identity.json @@ -0,0 +1,35 @@ +{ + "commandDigest": "sha256:3dc83b2c3493913748cacb09025a8c97b74f3b5bbb1dc204072d1a773fa9af3a", + "sourceDigest": "sha256:79188d089f3f6b575726af77fe055a58449144a373a0745dcad4be414cba8cd0", + "declaration": { + "schema": "paperclip.grok-profile-identity.v1", + "agent": "grok", + "agentProfileVersion": 12, + "agentServerPackage": "builtin:grok-acp", + "agentServerVersion": "1", + "agentRuntimePackage": "native:grok", + "agentRuntimeVersion": "1.0.13", + "requiresProviderPolicy": false, + "acpxVersion": "0.13.1", + "sources": { + "patches/acpx@0.13.1.patch": "sha256:1e7dae8c83bf8620f53f2bea13c175eeff95e7e955783f6f529bb1dcbdcdb6d2", + "packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts": "sha256:7d6e948b7cff4df52a9afc916db46ace0f42b4b2d3134ba0c21ee0a928857e62", + "packages/paperclip-runner/src/cli/acpx-sidecar-input.ts": "sha256:17948ad74350dc487ab20d24a0fb89daf0b426b98396454fff0e3856e922d82c", + "packages/paperclip-runner/src/drivers/acpx/admission-diagnostics.ts": "sha256:df8f1aa4f2d1bc63b2635bdd09cb8165c8b7681ddcc2618435c7c08ca90475af", + "packages/paperclip-runner/src/drivers/acpx/grok-effort.ts": "sha256:f1e46cf2e1e36b7704bd797b678e3db8db2003f97bdb9d827cfa3d7662698777", + "packages/paperclip-runner/src/drivers/acpx/grok-web-search.ts": "sha256:754b5ca2345c045421f6007f34999508d996e1cea035f0f1952b3d5f00072b9c", + "packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts": "sha256:1c4ff0be6da798b6fcc604d20a9198dcc8523a3806d563b5b5345b7260549f73", + "packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts": "sha256:7c47c05306ebe7d1ca6b5d046a96f5726a48adec4d27e7c16788f505f25b5db2", + "packages/paperclip-runner/src/drivers/acpx/runtime-host.ts": "sha256:13f9ac382a279a67adb8974c36bbed38fe43d9b93b87bd7a05e879ead7f03d63", + "packages/paperclip-runner/src/drivers/preconfigured-home.ts": "sha256:bb286f7ddc6e9597c56d46bf7ff6e3b556c5b680d7e147fd46518724432b083e", + "packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts": "sha256:04a49a24e3828ce8fca908de15e31ae932b8b1b4f7ecec0308627f6a8da9ba20", + "packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts": "sha256:26dd0cfbef8b2b401de6b7862a4af7f243fb703e23678a1a5ed8fa68d4784d42", + "packages/paperclip-runner/src/drivers/acpx/native-distribution-integrity.ts": "sha256:aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b", + "packages/paperclip-runner/src/providers/grok/launcher.cjs": "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", + "packages/paperclip-runner/src/providers/grok/platforms.json": "sha256:931eed3abbf2bf041e6c72e6e9486d976f4352613cf069b216ddf010c9a8e704", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs": "sha256:cdba0cbca6ea0e28928cae4817055dc38c527308570c6a03f0736fef0210fe9e", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs": "sha256:e97a7c4d2cf9be9c6e0ada78076a5551b01401518597ab81cb6fbd5a1faf4a5e", + "packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs": "sha256:f711428ec3bb9bcf723a5fd1382a3d266ed14e7ed5340b8a32eafe7357940144" + } + } +} diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 0d1e269cb82..9fd5b84ab8c 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -271,6 +271,44 @@ views, access failures, inactive runs and missing plugins contribute no task tab Closing a tab preserves dismissal during polling; available panels remain in the launcher. Only tab identity is persisted, never availability responses or secrets. +#### Runtime activity recording + +A runtime client captures Runner's opaque activity context before an eligible +operation, then sends a structured notification after successful use: + +```sh +context=$("$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" notification-context) +# Perform the operation; notify only on success. +"$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" record-activity "$context" example.runtime desktop.used +``` + +Runner supplies the matching absolute executable as +`PAPERCLIP_RUNTIME_NOTIFICATION_HELPER` and an atomic endpoint descriptor as +`PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT`. Configured task environment cannot +override them. Provider launch boundaries preserve both values. Codex receives +read access to the helper, descriptor and publisher lock, plus write access to +one named pipe. The pipe and lock live in a private temporary directory, outside +durable Runner state. The enclosing directories remain protected. + +The helper captures context locally. After success, it publishes newline-delimited +JSON through a nonblocking pipe. A leading newline terminates an interrupted +previous write. A read-only file lock serializes publishers with a 500 ms deadline. +Runner reuses its bounded line reader: 2 KiB per record, 128 queued records and +16 records per control-loop iteration. Idle transport blocks; cleanup removes +only its pipe, lock and empty private directory. + +A successful helper exit means publication to the pipe. Runner validates context +against the active run, deduplicates up to 128 activity pairs per authority, and +persists `runtime.activity.recorded` through its authenticated event stream. +Warm attachment and process recovery rotate context. The helper accepts no run +identity. Clients treat this hint as best effort, including with older runtimes. +Notify only after successful use; exclude help, schema discovery, status probes +and failures. No agent instruction is required. + +Identifiers are 1–160 Unicode code points, excluding C0 controls and DEL. +Core maps declared activity identifiers to its consumers and applies their +existing discovery, authorization and availability checks. + #### `taskDetailView` A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability. diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 525a38772f4..95d24a35638 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -52,8 +52,8 @@ assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).na const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js'))); const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js'))); const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'))); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 33); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:0260d5810e9247986800ad46c1f9b3511a5abd06cb8f142405d5caacbc3960fc'); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 34); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:ed4b32cc8fbf36309d38b3bd378f41f8e7b9e96a2405d275cc55e89042d94c7f'); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary();