fix(desktop): require scope match in runtime lifecycle capability

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-17 14:38:56 -04:00
co-authored by Will Pfleger
parent 3f933e1751
commit 0e77706443
3 changed files with 63 additions and 2 deletions
@@ -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();
}
@@ -123,6 +123,9 @@ pub(crate) fn put_managed_agent_runtime_lifecycle_for<R: tauri::Runtime>(
.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<R: tauri::Runtime>(
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()
@@ -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<String>,
/// ACP session config captured from this exact harness generation. Set by