From 0e77706443d925ff518fa86b8ba959d8fd204276 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 17 Aug 2026 14:38:56 -0400 Subject: [PATCH] fix(desktop): require scope match in runtime lifecycle capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit put_managed_agent_runtime_lifecycle_for validated pair key, start_nonce, and process liveness but never required the tracked runtime's scope_id to equal the current active scope — the same cross-scope leak class killed on the sibling put_agent_session_config (P23-C1). A same-pair/same-nonce lifecycle frame from a drained workspace was accepted and mutated the runtime after a workspace rotation. Capture the active scope before the runtime-map lock and reject any frame whose runtime.scope_id differs, mirroring the sibling command's shape. Add a capability test proving a stale-scope frame is rejected with the runtime lifecycle unmutated (no status emit). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../commands/agent_config_capability_tests.rs | 54 +++++++++++++++++++ .../src/managed_agents/runtime_commands.rs | 6 +++ .../src/managed_agents/runtime_types.rs | 5 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config_capability_tests.rs b/desktop/src-tauri/src/commands/agent_config_capability_tests.rs index 8543b41b3..ee1ccbbd5 100644 --- a/desktop/src-tauri/src/commands/agent_config_capability_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_capability_tests.rs @@ -506,3 +506,57 @@ fn lifecycle_sibling_rejects_stale_nonce() { ); h.reap(); } + +/// §3.3a sibling scope binding (P3B-I1): a same-pair/same-nonce frame on a +/// still-live runtime whose `scope_id` no longer matches the active scope — the +/// runtime survived but the workspace rotated — is rejected with zero mutation +/// and no status emit. This is the lifecycle-command analogue of +/// `frame_with_stale_scope_is_rejected`; the base checks (pair/nonce/liveness) +/// all pass, so only the scope check can reject it. +#[test] +fn lifecycle_sibling_rejects_stale_scope() { + let _guard = gen_guard(); + let pubkey = "aa".repeat(32); + let record = test_record(&pubkey); + let h = Harness::new(&record); + // Runtime stamped scope-a and live under the current nonce; the active + // scope then rotates to scope-b. + h.seed_runtime(&record, "scope-a", "nonce-1"); + h.switch_scope_to("scope-b"); + + let payload = crate::managed_agents::ManagedAgentRuntimeLifecycleObserverPayload { + pubkey: pubkey.clone(), + relay_url: TEST_RELAY.to_string(), + start_nonce: "nonce-1".to_string(), + lifecycle: crate::managed_agents::ManagedAgentRuntimeLifecycle::Ready, + error: None, + }; + let result = crate::managed_agents::put_managed_agent_runtime_lifecycle_for( + pubkey.clone(), + payload, + h.app.handle(), + ); + assert!( + result.is_err(), + "stale-scope lifecycle frame must be rejected even with matching pair/nonce/liveness" + ); + // Runtime unmutated: still Starting (seed), never advanced to the frame's + // Ready. An unchanged lifecycle also proves no status was emitted — the + // emit only runs on the Ok path after the mutation. + { + let key = ManagedAgentRuntimeKey::new(&pubkey, TEST_RELAY).unwrap(); + let state = h.state(); + let runtimes = state.managed_agent_processes.lock().unwrap(); + let rt = runtimes.get(&key).expect("runtime still tracked"); + assert_eq!( + rt.lifecycle, + crate::managed_agents::ManagedAgentRuntimeLifecycle::Starting, + "rejected frame must not mutate the runtime lifecycle" + ); + assert!( + rt.error.is_none(), + "rejected frame must not write an error onto the runtime" + ); + } + h.reap(); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 202aabe5e..3349eb011 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -123,6 +123,9 @@ pub(crate) fn put_managed_agent_runtime_lifecycle_for( .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) .ok_or_else(|| format!("agent {} not found", key.pubkey))?; + // Capture the active scope BEFORE taking the runtime lock so a concurrent + // workspace switch cannot slip a stale-scope frame past the check. + let current_scope_id = state.capture_active_scope().map(|scope| scope.scope_id); let mut runtimes = state .managed_agent_processes .lock() @@ -133,6 +136,9 @@ pub(crate) fn put_managed_agent_runtime_lifecycle_for( if runtime.start_nonce != payload.start_nonce { return Err("lifecycle frame does not match the current harness generation".into()); } + if runtime.scope_id != current_scope_id { + return Err("lifecycle frame does not match the current workspace scope".into()); + } let exited = runtime .child .try_wait() diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index b715d6cc0..0647a1971 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -54,8 +54,9 @@ pub struct ManagedAgentPairRuntime { /// Scope ID of the workspace this runtime was spawned into. Used by drain /// filtering and `list_managed_agent_runtimes` to detect cross-scope /// entries, and by the runtime-capability commands (`put_agent_session_config` - /// / `get_agent_config_surface`) to require that a session-config frame or - /// read matches the CURRENT active scope — the seam that keeps a delayed + /// / `get_agent_config_surface` / `put_managed_agent_runtime_lifecycle`) to + /// require that a session-config frame, config read, or lifecycle frame + /// matches the CURRENT active scope — the seam that keeps a delayed /// frame from a drained workspace from surfacing under a rotated identity. pub scope_id: Option, /// ACP session config captured from this exact harness generation. Set by