mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user