From 27be8590442fc02d29a6c257a4e9dbe5bbbd66d2 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 29 Oct 2026 07:40:50 -0700 Subject: [PATCH] Record runtime activity under Runner authority (#18050) ## Thinking Path > - Paperclip manages agents and tasks. > - Runner owns each authenticated run and its provider process. > - Runtime clients need to report successful activity in that run. > - A retained process can outlive a run, so an operation needs current authority. > - This PR captures that authority before the operation and records activity afterward. > - Core can consume the event through the existing run stream. ## Linked Issues or Issue Description Builds on merged #18129 and targets master. The dependent consumer is #18131. Test stability fixes were separated into merged #18128. Earlier combined-head verification remains in [the archive](https://github.com/paperclipai/paperclip/pull/18050#issuecomment-6489369676). **Subsystem affected** Runner activity recording and provider command environments. **Problem or motivation** A retained provider process can complete an operation after its original run ends. Runtime clients need a host-attributed event and a helper that works inside the provider sandbox. **Proposed solution** Capture opaque Runner context before use. Publish a structured notification after success. Runner checks current authority and records `runtime.activity.recorded`. ## What Changed - Add `notification-context` and `record-activity CONTEXT NAMESPACE ACTIVITY_KEY`. Rotate context on warm attachment and process recovery. Accept no caller run ID. - Use one private named pipe. Reuse Runner's bounded JSON-line reader. Serialize concurrent publishers with a read-only lock. A blocking reader queues at most 128 records; the existing control loop handles at most 16 per pass. - Grant Codex access to the exact helper, descriptor, lock and pipe. Preserve other filesystem and network limits. Preserve host discovery values through provider launch boundaries and reject configured-environment overrides. - Update source identities to Pi 34, Copilot 81 and Grok 12. Keep historical identity fixtures and recovery behavior. ## Verification - Targeted release notification and sandbox-policy tests: seven passed. They cover run rotation, recovery, Unicode limits, concurrent writers, malformed and partial records, bounded queues, failed persistence and fixed-path cleanup. - Real CLI integration and pinned Codex 0.160.0 sandbox checks: passed. The sandbox check uses no model, credential or network call. It verifies activity recording while unrelated state reads and parent-directory writes remain denied. - ACPX profile generator check, formatting and diff checks: passed. - Rebased onto master `31b927e30` at head `64d0248a8`. Master used the same profile versions, so the resolution preserves its Pi 33, Copilot 79 and Grok 11 fixtures byte-for-byte and creates new identities. All identity checks pass. Five targeted provider/environment/recovery files pass 168 tests with one existing skip; the Rust Pi admission regression passes. The notification module and Codex sandbox implementation are byte-identical to the tested pipe head `671373fe0`. - Removed the mailbox traversal reported by security review. Its regression replaces the pipe path with a 100-level tree and an unreadable leaf. Shutdown preserves the leaf, performs no traversal and completes promptly, including with a partial record. - An initial targeted test failed because its run-rotation fixture updated config without updating durable state. The corrected fixture passes. Runner also rejects inconsistent run bindings. - Previous head `26abbf62b` passed Runner, build and browser CI. Server shard 11/12 failed in unchanged `attention-service.test.ts`: cleanup/test timeouts were followed by a duplicate `ATQ` fixture key. The final head needs fresh CI and review. - CI on `64d0248a8` completed with 52 successful checks and four intentional skips. Runner Vitest shard 1/2 failed when retained-state copies encountered the live FIFO; the aggregate also failed. Standard review scored 4/5 and found relative pipe paths plus late context publication. Follow-up `5c3f14d32` places IPC in a private canonical temporary directory and publishes context before welcome commands. Its relative-state CLI test, two authenticated reconnect cases, seven notification/policy cases and real Codex sandbox pass. One affected retained-settlement case passes with the matching release binary; the other 18 cases remain for CI. The sandbox denies sibling-directory and lock writes. All build, test and security checks pass on `5c3f14d32`; standard review is 5/5 with no unresolved threads. The requested Apex review is 5/5 on this exact head, with no findings or unresolved threads. - Earlier broad test results in the archive apply to their recorded source revisions. Per the requested workflow, final local checks stay targeted; GitHub CI supplies broad coverage. - After Apex completed, master advanced again and conflicted on Copilot identity. Head `e94a9359d` is rebased on master `8ac5a128e`; it preserves the published Copilot 80 fixture and uses Copilot 81. The notification module, Codex sandbox and durable control-loop code are byte-identical to Apex-reviewed `5c3f14d32`. All 131 targeted installation/compatibility/recovery cases and identity generator checks pass. All 55 checks pass with four intentional skips on the rebased head. Standard review is 5/5 with no unresolved threads. Apex is 5/5 on `e94a9359d`, with zero findings and no unresolved threads. GitHub confirms this head is conflict-free and ready for review. ## Risks Activity remains an optional hint and grants no access. A successful helper exit means publication to the pipe; Runner persists accepted events through its existing durable outbox. Full queues, unavailable helpers or failed writes can drop a hint without failing the operation. Context reads are local and do not wait for the control loop. Publishers have a 500 ms lock deadline and a 2 KiB record limit. IPC lives outside durable state. Idle transport blocks; cleanup removes its fixed files and empty private directory. The host must provide `/usr/bin/mkfifo`. Rebuild matching Runner/provider artifacts for the updated source identities. No dependency, migration, live provider call or deployment is included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools and code execution. The exact serving snapshot is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/run-log-events.md | 6 + packages/paperclip-runner/README.md | 16 + packages/paperclip-runner/acpx-profiles.json | 12 +- packages/paperclip-runner/protocol/README.md | 8 + .../runner-core/src/acpx_provider_backend.rs | 4 +- .../runner-core/src/bin/paperclip-runnerd.rs | 24 + .../crates/runner-core/src/codex_provider.rs | 129 +++ .../runner-core/src/configured_environment.rs | 4 + .../crates/runner-core/src/durable/runner.rs | 11 + .../runner-core/src/durable/transport.rs | 12 + .../src/generated_acpx_profiles.rs | 6 +- .../runner/crates/runner-core/src/lib.rs | 3 + .../runner-core/src/process_supervisor.rs | 2 + .../runner-core/src/runtime_notifications.rs | 784 ++++++++++++++++++ .../tests/native_provider_backend.rs | 2 +- .../tests/runtime_notifications.rs | 123 +++ .../scripts/copilot-profile-identity.mjs | 4 +- .../scripts/grok-profile-identity.mjs | 4 +- .../backends/native-backend-factory.test.ts | 3 + .../src/configured-environment.ts | 2 +- .../drivers/acpx/copilot-installation.test.ts | 4 +- .../src/drivers/acpx/environment.test.ts | 6 + .../src/drivers/acpx/environment.ts | 2 + .../src/drivers/acpx/generated-profiles.ts | 12 +- .../src/drivers/acpx/pi-installation.test.ts | 38 +- .../src/drivers/acpx/profile-compatibility.ts | 6 +- .../drivers/acpx/recovery-identity.test.ts | 7 + .../opencode/opencode-server-driver.test.ts | 4 + .../opencode/opencode-server-driver.ts | 2 + .../src/providers/grok/launcher.cjs | 2 +- .../pi-acp/profile-v34-identity.json | 52 ++ .../copilot-profile-v81-identity.json | 88 ++ .../test/fixtures/fake-opencode-server.mjs | 2 + .../fixtures/grok-profile-v12-identity.json | 35 + packages/plugins/sdk/README.md | 38 + scripts/pi-public-install-probe.mjs | 4 +- 36 files changed, 1416 insertions(+), 45 deletions(-) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs create mode 100644 packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs create mode 100644 packages/paperclip-runner/test-fixtures/pi-acp/profile-v34-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v81-identity.json create mode 100644 packages/paperclip-runner/test/fixtures/grok-profile-v12-identity.json 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();