diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml index 667b4841418..83425e33c27 100644 --- a/.github/workflows/_ci-desktop.yml +++ b/.github/workflows/_ci-desktop.yml @@ -88,6 +88,10 @@ jobs: run: just desktop-tauri-check env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + - name: Desktop Tauri remote credential fixtures + run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --no-default-features managed_agents::runtime_configurations::tests::remote_credentials -- --test-threads=1 + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Desktop Tauri tests run: just desktop-tauri-test env: diff --git a/Justfile b/Justfile index e44d3158777..3a18d0b321e 100644 --- a/Justfile +++ b/Justfile @@ -445,8 +445,10 @@ test-unit: # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. + # The author-only fanout family uses lazy pools and in-memory recipients; + # include Stop request/result privacy and its sibling private kinds. cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)' + -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::event::tests::fanout_access::.*_delivers_to_author_only$/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ddd594b142d..ddb7811a843 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2496,6 +2496,10 @@ async fn tokio_main() -> Result<()> { .init(); let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + let (startup_model, require_model) = startup_model_selection( + config.model.as_deref(), + std::env::var("BUZZ_ACP_REQUIRED_MODEL"), + )?; // ── Setup-mode early branch ─────────────────────────────────────────────── // @@ -3100,7 +3104,8 @@ async fn tokio_main() -> Result<()> { acp, state: SessionState::default(), model_capabilities: None, - desired_model: config.model.clone(), + desired_model: startup_model.clone(), + require_model, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -5442,10 +5447,29 @@ impl PoolStartup { } } +// Named launches carry the target, not a boolean claiming it was selected. +// This is independent of BUZZ_ACP_MODEL: Claude A1 deliberately removes that +// switch hint and selects through ANTHROPIC_MODEL at launch. Session creation +// must still verify the required target from the adapter's actual response. +fn startup_model_selection( + legacy_model: Option<&str>, + required_model: Result, +) -> Result<(Option, bool)> { + match required_model { + Ok(model) if !model.trim().is_empty() => Ok((Some(model), true)), + Err(std::env::VarError::NotPresent) => Ok((legacy_model.map(str::to_owned), false)), + _ => anyhow::bail!("BUZZ_ACP_REQUIRED_MODEL must be a non-empty UTF-8 model ID"), + } +} + async fn initialize_agent_pool( startup: &PoolStartup, mut shutdown: Option>, ) -> Result { + let (desired_model, require_model) = startup_model_selection( + startup.model.as_deref(), + std::env::var("BUZZ_ACP_REQUIRED_MODEL"), + )?; // One agent failing to start must not kill the whole pool. // Attempt each spawn under a 60-second timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); @@ -5502,7 +5526,8 @@ async fn initialize_agent_pool( acp, state: SessionState::default(), model_capabilities: None, - desired_model: startup.model.clone(), + desired_model: desired_model.clone(), + require_model, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9358,6 +9383,7 @@ mod error_outcome_emission_tests { state: Default::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -11248,3 +11274,29 @@ mod observer_payload_trim_tests { assert!(leaf.contains("[elided")); } } + +#[cfg(test)] +mod startup_model_selection_tests { + use super::startup_model_selection; + use std::env::VarError; + + #[test] + fn required_target_is_independent_of_legacy_switch_hint() { + for legacy in [None, Some("legacy")] { + assert_eq!( + startup_model_selection(legacy, Ok("chosen".into())).unwrap(), + (Some("chosen".into()), true), + ); + assert_eq!( + startup_model_selection(legacy, Err(VarError::NotPresent)).unwrap(), + (legacy.map(str::to_owned), false), + ); + } + for value in ["", " "] { + assert!(startup_model_selection(Some("legacy"), Ok(value.into())).is_err()); + } + assert!( + startup_model_selection(None, Err(VarError::NotUnicode("invalid".into()))).is_err() + ); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 06383d456d3..5f72015d731 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -236,6 +236,9 @@ pub struct OwnedAgent { pub model_capabilities: Option, /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. pub desired_model: Option, + /// Require session evidence for `desired_model` before any prompt. Set only + /// for explicit named launches; legacy consumers retain best-effort switching. + pub require_model: bool, /// Whether `desired_model` was set by a live `SwitchModel` control signal /// (as opposed to being derived from config/persona at spawn). Used by the /// desktop reader to distinguish a genuine runtime override from a stale @@ -1390,6 +1393,17 @@ async fn create_session_and_apply_model( ctx.session_title.as_deref(), ); + if agent.require_model + && agent + .desired_model + .as_deref() + .is_none_or(|model| model.trim().is_empty()) + { + return Err(AcpError::Protocol( + "Selected runtime requires a non-empty model; refusing fallback".into(), + )); + } + let resp = agent .acp .session_new_full( @@ -1436,8 +1450,9 @@ async fn create_session_and_apply_model( // Apply desired_model if set, matching against the fresh session/new // response. `post_switch_snapshot` drives everything downstream: - // `Some(value)` → a switch applied; `value` is the adapter's post-switch - // RPC response, whose `configOptions` describe the target + // `Some(value)` → target confirmed at launch, or a switch applied; + // `value` is the corresponding adapter snapshot. Its + // `configOptions` describe the target // model. Effort resolution and the Desktop capture both // read it so they converge on the model the session is // actually running, not the pre-switch default. @@ -1448,98 +1463,152 @@ async fn create_session_and_apply_model( agent.desired_model { // Consume the busy-path pending-ack once for this apply: only the - // `Applied` arm turns it into a positive terminal; the rejection and + // confirmed-current/`Applied` arms emit a positive terminal; rejection and // unsupported arms already emit their own correlated failure frame, so // taking it here keeps a leftover flag from firing a spurious success // on some later unrelated session. let pending_ack = std::mem::take(&mut agent.desired_model_pending_ack); - match resolve_model_switch_method(&resp.raw, desired) { - Some(method) => { - match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await? - { - ModelSwitchOutcome::Applied(switch_result) => { - // The adapter rebuilds `session.configOptions` for the - // target model and echoes them here. Refresh capabilities - // from that authoritative snapshot when present so the - // idle-switch guard and the panel reflect the target - // model; drop to `None` (re-derive next session) when the - // adapter returned no options so a pre-switch snapshot is - // never mistaken for the target model's. - if switch_result - .get("configOptions") - .is_some_and(|v| !v.is_null()) - { - agent.model_capabilities = Some(AgentModelCapabilities { - config_options_raw: extract_model_config_options(&switch_result), - available_models_raw: extract_model_state(&switch_result), - thought_level_config_id: extract_thought_level_config_id( + // Launch-configured models (Claude A1, Goose, buzz-agent) need no RPC + // when the fresh session already reports the exact target. A launch + // environment or catalog membership alone is not evidence of selection. + if agent.require_model && session_reports_model(&resp.raw, desired) { + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", "status": "switched", + "modelId": desired, "requestId": agent.desired_model_request_id, + }), + ); + } + Some(resp.raw.clone()) + } else { + match resolve_model_switch_method(&resp.raw, desired) { + Some(method) => { + match apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method) + .await? + { + ModelSwitchOutcome::Applied(switch_result) => { + if agent.require_model + && !switch_reports_model( &switch_result, - ), - }); - } else { - agent.model_capabilities = None; + &resp.session_id, + desired, + &method, + ) + { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", "status": "failure", + "modelId": desired, "requestId": agent.desired_model_request_id, + }), + ); + return Err(AcpError::Protocol( + concat!( + "Selected runtime model was not confirmed by the adapter; ", + "refusing fallback" + ) + .into(), + )); + } + // The adapter rebuilds `session.configOptions` for the + // target model and echoes them here. Refresh capabilities + // from that authoritative snapshot when present so the + // idle-switch guard and the panel reflect the target + // model; drop to `None` (re-derive next session) when the + // adapter returned no options so a pre-switch snapshot is + // never mistaken for the target model's. + if switch_result + .get("configOptions") + .is_some_and(|v| !v.is_null()) + { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options( + &switch_result, + ), + available_models_raw: extract_model_state(&switch_result), + thought_level_config_id: extract_thought_level_config_id( + &switch_result, + ), + }); + } else { + agent.model_capabilities = None; + } + // Busy-path deferred switch: emit a positive terminal so + // the Desktop confirms success from a real frame instead + // of inferring it from timeout silence. Gated on the + // pending-ack flag so the idle path (which already acked + // `switched` immediately) does not double-emit. + if pending_ack { + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "switched", + "modelId": desired, + "requestId": agent.desired_model_request_id, + }), + ); + } + Some(switch_result) } - // Busy-path deferred switch: emit a positive terminal so - // the Desktop confirms success from a real frame instead - // of inferring it from timeout silence. Gated on the - // pending-ack flag so the idle path (which already acked - // `switched` immediately) does not double-emit. - if pending_ack { + ModelSwitchOutcome::Rejected => { + // The adapter explicitly rejected the switch: the session + // is still on its default model. Surface a terminal + // failure so the Desktop ModelPicker rejects the live pick + // instead of falsely reporting success, and preserve the + // pre-switch capabilities the session is really running. agent.acp.observe( "control_result", serde_json::json!({ "type": "switch_model", - "status": "switched", + "status": "failure", "modelId": desired, + // Echo the pick's request_id so the Desktop can + // correlate this late frame to the operation + // that fired it, and ignore replayed results. "requestId": agent.desired_model_request_id, }), ); + if agent.require_model { + return Err(AcpError::Protocol( + "Selected runtime model was rejected; refusing fallback".into(), + )); + } + None } - Some(switch_result) } - ModelSwitchOutcome::Rejected => { - // The adapter explicitly rejected the switch: the session - // is still on its default model. Surface a terminal - // failure so the Desktop ModelPicker rejects the live pick - // instead of falsely reporting success, and preserve the - // pre-switch capabilities the session is really running. - agent.acp.observe( - "control_result", - serde_json::json!({ - "type": "switch_model", - "status": "failure", - "modelId": desired, - // Echo the pick's request_id so the Desktop can - // correlate this late frame to the operation - // that fired it, and ignore replayed results. - "requestId": agent.desired_model_request_id, - }), + } + None => { + if !agent.require_model { + tracing::warn!( + target: "pool::model", + "desired model {desired} not found in agent's available models — proceeding with agent default" ); - None } + // Surface the miss so the desktop ModelPicker can reject a live + // pick rather than silently no-op. On the busy path the turn has + // already been cancelled+requeued by the time we get here, so the + // turn restarts on the unchanged model and the user is told no. + agent.acp.observe( + "control_result", + serde_json::json!({ + "type": "switch_model", + "status": "unsupported_model", + "modelId": desired, + // Echo the pick's request_id (see the failure arm). + "requestId": agent.desired_model_request_id, + }), + ); + if agent.require_model { + return Err(AcpError::Protocol( + "Selected runtime model is unavailable; refusing fallback".into(), + )); + } + None } } - None => { - tracing::warn!( - target: "pool::model", - "desired model {desired} not found in agent's available models — proceeding with agent default" - ); - // Surface the miss so the desktop ModelPicker can reject a live - // pick rather than silently no-op. On the busy path the turn has - // already been cancelled+requeued by the time we get here, so the - // turn restarts on the unchanged model and the user is told no. - agent.acp.observe( - "control_result", - serde_json::json!({ - "type": "switch_model", - "status": "unsupported_model", - "modelId": desired, - // Echo the pick's request_id (see the failure arm). - "requestId": agent.desired_model_request_id, - }), - ); - None - } } } else { None @@ -1640,6 +1709,57 @@ fn mcp_servers_with_git_origin( servers } +// Prefer no guessed aliases: IDs must match exactly. If both stable and +// unstable state are reported they must agree; an available-model catalog is +// not current-model evidence. This also accepts launch-only adapters with no +// switching catalog at all. +fn session_reports_model(snapshot: &serde_json::Value, desired: &str) -> bool { + let options = extract_model_config_options(snapshot); + let mut current = options + .iter() + .map(|option| option.get("currentValue").and_then(|value| value.as_str())) + .chain( + snapshot + .get("models") + .filter(|models| !models.is_null()) + .map(|models| { + models + .get("currentModelId") + .and_then(|value| value.as_str()) + }), + ) + .peekable(); + current.peek().is_some() && current.all(|model| model == Some(desired)) +} + +fn switch_reports_model( + snapshot: &serde_json::Value, + session_id: &str, + desired: &str, + method: &ModelSwitchMethod, +) -> bool { + if snapshot + .get("sessionId") + .is_some_and(|id| id.as_str() != Some(session_id)) + || snapshot + .get("modelId") + .is_some_and(|id| id.as_str() != Some(desired)) + { + return false; + } + if session_reports_model(snapshot, desired) { + return true; + } + // buzz-agent's set_model_session sets effective_model before returning this + // explicit receipt. Do not extend that evidence to an empty/ok-only reply, + // or let it override contradictory configOptions/models state. + matches!(method, ModelSwitchMethod::SetModel { .. }) + && extract_model_config_options(snapshot).is_empty() + && snapshot.get("models").is_none_or(|models| models.is_null()) + && snapshot.get("modelId").and_then(|value| value.as_str()) == Some(desired) + && snapshot.get("sessionId").and_then(|value| value.as_str()) == Some(session_id) +} + /// Outcome of a live model-switch RPC returned by [`apply_model_switch`]. /// /// `Applied` and `Rejected` are distinct outcomes and must not be collapsed: @@ -1723,7 +1843,7 @@ async fn apply_model_switch( Ok(Err(e)) => { tracing::warn!( target: "pool::model", - "failed to set model {desired} via {method_label}: {e} — proceeding with agent default" + "adapter rejected model {desired} via {method_label}: {e}" ); Ok(ModelSwitchOutcome::Rejected) } @@ -6575,6 +6695,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -6675,6 +6796,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -6853,6 +6975,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -7007,6 +7130,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -7450,6 +7574,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -7532,6 +7657,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_name: "test".into(), goose_system_prompt_supported: None, protocol_version: 2, + require_model: false, }; agent.state.sessions.insert(scope, "sess".into()); agent @@ -8417,6 +8543,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -8478,6 +8605,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9593,6 +9721,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -9987,6 +10116,7 @@ mod startup_effort_tests { state: SessionState::default(), model_capabilities: None, desired_model: None, + require_model: false, model_overridden: false, desired_model_request_id: None, desired_model_pending_ack: false, @@ -10247,6 +10377,7 @@ mod model_switch_tests { state: SessionState::default(), model_capabilities: None, desired_model: Some(desired_model.to_string()), + require_model: false, model_overridden: true, desired_model_request_id: None, desired_model_pending_ack: false, @@ -10262,13 +10393,21 @@ mod model_switch_tests { /// `switch_reply` (a JSON-RPC `result`/`error` body minus the id). Any later /// request gets `{"ok":true}`. async fn spawn_switch_acp(session_new_options: &str, switch_reply: &str) -> AcpClient { + spawn_switch_snapshot_acp( + &format!(r#"{{"sessionId":"sess-1","configOptions":{session_new_options}}}"#), + switch_reply, + ) + .await + } + + async fn spawn_switch_snapshot_acp(session_new: &str, switch_reply: &str) -> AcpClient { let script = format!( r#"count=0 while IFS= read -r line; do count=$((count + 1)) id=$((count - 1)) if [ "$count" -eq 1 ]; then - printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{session_new_options}}}}}' + printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{session_new}}}' elif [ "$count" -eq 2 ]; then printf '%s\n' '{{"jsonrpc":"2.0","id":'"$id"',{switch_reply}}}' else @@ -10301,6 +10440,243 @@ done"# // agent wants to switch to. const OPTS_MODEL_A_AND_B: &str = r#"[{"configId":"model","category":"model","currentValue":"model-a","options":[{"value":"model-a"},{"value":"model-b"}]}]"#; + async fn create_test_session(agent: &mut OwnedAgent) -> Result { + create_session_and_apply_model( + agent, + &make_prompt_context_no_owner(), + None, + NewSessionChannelContext { + huddle_instructions: None, + canvas: None, + name: None, + scope: None, + channel_type: None, + }, + ) + .await + } + + #[test] + fn required_model_evidence_rejects_missing_and_conflicting_state() { + for (snapshot, matches) in [ + (serde_json::json!({}), false), + ( + serde_json::json!({"models":{"availableModels":[{"modelId":"model-b"}]}}), + false, + ), + ( + serde_json::json!({"models":{"currentModelId":"model-b"}}), + true, + ), + ( + serde_json::json!({"configOptions":[{"category":"model","currentValue":"model-b"}]}), + true, + ), + ( + serde_json::json!({"models":{"currentModelId":"model-a"}, + "configOptions":[{"category":"model","currentValue":"model-b"}]}), + false, + ), + ] { + assert_eq!(session_reports_model(&snapshot, "model-b"), matches); + } + let method = ModelSwitchMethod::SetModel { + model_id: "model-b".into(), + }; + for (snapshot, matches) in [ + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-b"}), + true, + ), + ( + serde_json::json!({"sessionId":"other","modelId":"model-b"}), + false, + ), + (serde_json::json!({"modelId":"model-b"}), false), + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-b","models":{"currentModelId":"model-a"}}), + false, + ), + ( + serde_json::json!({"sessionId":"sess-1","modelId":"model-a","models":{"currentModelId":"model-b"}}), + false, + ), + ] { + assert_eq!( + switch_reports_model(&snapshot, "sess-1", "model-b", &method), + matches + ); + } + } + + #[tokio::test] + async fn required_model_switch_requires_actual_confirmation() { + for (reply, succeeds) in [ + ( + r#""result":{"configOptions":[{"id":"model","category":"model","currentValue":"model-b"}]}"#, + true, + ), + ( + r#""result":{"configOptions":[{"configId":"model","category":"model","currentValue":"model-a"}]}"#, + false, + ), + (r#""result":{"ok":true}"#, false), + (r#""error":{"code":-32602,"message":"rejected"}"#, false), + ] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, reply).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + agent.desired_model_pending_ack = true; + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + assert_eq!(create_test_session(&mut agent).await.is_ok(), succeeds); + let results = control_results(&obs); + assert_eq!(results.len(), 1); + assert_eq!( + results[0]["status"], + if succeeds { "switched" } else { "failure" } + ); + assert_eq!( + obs.snapshot() + .iter() + .any(|e| e.kind == "session_config_captured"), + succeeds + ); + assert!(!obs + .snapshot() + .iter() + .any(|e| e.kind == "acp_write" && e.payload["method"] == "session/prompt")); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_missing_or_unavailable_never_falls_back() { + for desired in [None, Some(""), Some("model-unavailable")] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, r#""result":{}"#).await; + let mut agent = switching_agent(acp, "unused"); + agent.desired_model = desired.map(str::to_owned); + agent.require_model = true; + assert!(matches!( + create_test_session(&mut agent).await, + Err(AcpError::Protocol(_)) + )); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_accepts_launch_selection_without_switch_catalog() { + // Same launch authority as Claude A1; the child reports the model it + // actually read from launch env. No switching API/catalog is offered. + let script = r#"id=0 +while IFS= read -r line; do +printf '%s\n' '{"jsonrpc":"2.0","id":'"$id"',"result":{"sessionId":"sess-1","configOptions":[{"id":"model","category":"model","currentValue":"'"$ANTHROPIC_MODEL"'"}]}}' +id=$((id + 1)) +done"#; + let acp = AcpClient::spawn( + "bash", + &["-c".into(), script.into()], + &[("ANTHROPIC_MODEL".into(), "model-b".into())], + false, + ) + .await + .unwrap(); + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + agent.desired_model_pending_ack = true; + let obs = observer::ObserverHandle::in_process(); + agent.acp.set_observer(Some(obs.clone()), 0); + // Re-check every fresh session, not just the first cached capability set. + for _ in 0..2 { + assert!(create_test_session(&mut agent).await.is_ok()); + } + assert_eq!(capture(&obs)["configOptions"][0]["currentValue"], "model-b"); + assert_eq!(control_results(&obs).len(), 1); + assert!(obs + .snapshot() + .iter() + .filter(|e| e.kind == "acp_write") + .all(|e| e.payload["method"] == "session/new")); + agent.acp.shutdown().await; + } + + #[tokio::test] + async fn required_model_uses_buzz_agent_session_and_switch_receipts() { + // Shapes emitted by buzz-agent::session_new and set_model_session. + for (current, reply, succeeds) in [ + ( + "model-b", + r#""error":{"code":-32601,"message":"no switch API"}"#, + true, + ), + ( + "model-a", + r#""result":{"sessionId":"sess-1","modelId":"model-b"}"#, + true, + ), + ("model-a", r#""result":{}"#, false), + ( + "model-a", + r#""result":{"sessionId":"sess-1","modelId":"model-a"}"#, + false, + ), + ] { + let raw = serde_json::json!({"sessionId":"sess-1", "models": { + "currentModelId":current, "availableModels":[{"modelId":"model-b"}] + }}); + let acp = spawn_switch_snapshot_acp(&raw.to_string(), reply).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert_eq!(create_test_session(&mut agent).await.is_ok(), succeeds); + agent.acp.shutdown().await; + } + } + + #[tokio::test] + async fn required_model_rejects_unverified_launch_and_transport_failure() { + for raw in [ + serde_json::json!({"sessionId":"sess-1"}), + serde_json::json!({"sessionId":"sess-1","models":{"currentModelId":"model-a"}}), + serde_json::json!({"sessionId":"sess-1","models":{"currentModelId":"model-b"}, + "configOptions":[{"category":"model","currentValue":"model-a"}]}), + ] { + let acp = spawn_switch_snapshot_acp(&raw.to_string(), r#""result":{}"#).await; + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert!(create_test_session(&mut agent).await.is_err()); + agent.acp.shutdown().await; + } + let script = format!( + r#"IFS= read -r line +printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"sessionId":"sess-1","configOptions":{OPTS_MODEL_A_AND_B}}}}}' +IFS= read -r line +exit 0"# + ); + let acp = AcpClient::spawn("bash", &["-c".into(), script], &[], false) + .await + .unwrap(); + let mut agent = switching_agent(acp, "model-b"); + agent.require_model = true; + assert!(create_test_session(&mut agent).await.is_err()); + agent.acp.shutdown().await; + } + + #[tokio::test] + async fn default_model_retains_legacy_best_effort_behavior() { + for (desired, reply) in [ + ("unavailable", r#""result":{}"#), + ("model-b", r#""error":{"code":-32602,"message":"rejected"}"#), + ("model-b", r#""result":{}"#), + ] { + let acp = spawn_switch_acp(OPTS_MODEL_A_AND_B, reply).await; + let mut agent = switching_agent(acp, desired); + assert!(!agent.require_model); + assert!(create_test_session(&mut agent).await.is_ok()); + agent.acp.shutdown().await; + } + } + #[tokio::test] async fn session_new_sends_policy_specific_base_and_scope_specific_title() { use crate::scope::SessionPolicy; @@ -10822,6 +11198,7 @@ done"# state: SessionState::default(), model_capabilities: None, desired_model: Some(desired_model.to_string()), + require_model: false, model_overridden: true, desired_model_request_id: None, desired_model_pending_ack: false, diff --git a/crates/buzz-core/src/desktop_capabilities.rs b/crates/buzz-core/src/desktop_capabilities.rs new file mode 100644 index 00000000000..ef1c4d8f63a --- /dev/null +++ b/crates/buzz-core/src/desktop_capabilities.rs @@ -0,0 +1,209 @@ +//! Bounded, owner-private runtime facts, not signing access or agent readiness. +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_CAPABILITIES}; +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +/// Allowlisted projection of a built-in runtime; never catalog paths or auth data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeFact { + /// Built-in catalog identifier. + pub id: String, + /// Discovery's installation/adapter availability, not authentication. + pub availability: String, + /// Whether a separate vendor CLI is required. + pub requires_external_cli: bool, + /// Spawn policy cap; None means no configured cap, not infinite capacity. + pub max_parallelism: Option, +} + +/// Facts at the signed event time, changed only when the projection changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopCapabilities { + /// Format version. + pub v: u8, + /// Encrypted canonical community. + pub community: String, + /// Local Desktop coordinate. + pub id: String, + /// Sorted, unique built-in runtime facts. + pub runtimes: Vec, +} + +/// Validate the bounded public envelope without decrypting it. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_CAPABILITIES) +} + +impl DesktopCapabilities { + /// Project onto the persisted Desktop coordinate, not a caller-selected host. + pub fn new(profile: DesktopProfile, mut runtimes: Vec) -> Self { + runtimes.sort_by(|a, b| a.id.cmp(&b.id)); + Self { + v: 1, + community: profile.community, + id: profile.id, + runtimes, + } + } + + fn validate(&self) -> Result<(), String> { + DesktopProfile::new(self.community.clone(), self.id.clone())?; + if self.v != 1 + || self.runtimes.len() > 8 + || self.runtimes.windows(2).any(|r| r[0].id >= r[1].id) + || self.runtimes.iter().any(|r| { + r.id.is_empty() + || r.id.len() > 32 + || !r.id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + || !matches!( + r.availability.as_str(), + "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed" + ) + || r.max_parallelism == Some(0) + }) + { + return Err("invalid Desktop runtime facts".into()); + } + Ok(()) + } + + /// Encrypt/sign once, then persist these exact bytes for retries. + pub fn sign(&self, keys: &Keys) -> Result { + self.sign_at(keys, nostr::Timestamp::now()) + } + + /// Sign at an observed wall-clock second, never a synthesized logical time. + pub fn sign_at(&self, keys: &Keys, observed: nostr::Timestamp) -> Result { + self.validate()?; + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + let event = EventBuilder::new(Kind::Custom(KIND_DESKTOP_CAPABILITIES as u16), content) + .tag(Tag::identifier(&self.id)) + .custom_created_at(observed) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + validate_envelope(&event)?; + Ok(event) + } + + /// Bounded history/live merge: newest signed time, lower event ID on ties. + pub fn read_latest( + mut events: Vec, + keys: &Keys, + community: &str, + ) -> Result, String> { + if events.len() > 100 { + return Err("too many Desktop reports".into()); + } + events.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id))); + let mut seen = std::collections::HashSet::new(); + let mut rows = Vec::new(); + for event in events { + let report = Self::read(&event, keys, community)?; + if seen.insert(report.id.clone()) { + rows.push((report, event.created_at.as_secs())); + } + } + Ok(rows) + } + + /// Authenticate, decrypt and scope-check before exposing any fact. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop report signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop report".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop report decryption failed")?; + let report: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop report")?; + report.validate()?; + if report.community != community || Some(report.id.as_str()) != event.tags.identifier() { + return Err("Desktop report scope mismatch".into()); + } + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_scoped_bounded_facts() { + let keys = Keys::generate(); + let mut report = DesktopCapabilities::new( + DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(), + vec![], + ); + let event = report.sign(&keys).unwrap(); + assert_eq!( + DesktopCapabilities::read(&event, &keys, &report.community).unwrap(), + report + ); + assert!(DesktopCapabilities::read(&event, &keys, "wss://two.example").is_err()); + assert!(DesktopCapabilities::read(&event, &Keys::generate(), &report.community).is_err()); + let mut payload = serde_json::to_value(&report).unwrap(); + payload["auth"] = serde_json::json!("must not appear"); + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, ciphertext) + .tags(event.tags.clone()) + .sign_with_keys(&keys) + .unwrap(); + assert!(DesktopCapabilities::read(&invalid, &keys, &report.community).is_err()); + let mut tampered = event; + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopCapabilities::read(&tampered, &keys, &report.community).is_err()); + report.runtimes.push(RuntimeFact { + id: "/private/path".into(), + availability: "available".into(), + requires_external_cli: false, + max_parallelism: None, + }); + assert!(report.sign(&keys).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_CAPABILITIES)); + report.runtimes[0].id = "goose".into(); + let old = report.sign(&keys).unwrap(); + report.runtimes[0].availability = "cli_missing".into(); + let new = report.sign(&keys).unwrap(); + let signed = |event: &Event, time| { + EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .custom_created_at(nostr::Timestamp::from(time)) + .sign_with_keys(&keys) + .unwrap() + }; + let a = signed(&old, 20); + let b = signed(&new, 20); + let winner = if a.id < b.id { &a } else { &b }; + let expected = DesktopCapabilities::read(winner, &keys, &report.community).unwrap(); + for events in [vec![signed(&old, 10), a.clone(), b.clone()], vec![b, a]] { + assert_eq!( + DesktopCapabilities::read_latest(events, &keys, &report.community).unwrap(), + vec![(expected.clone(), 20)] + ); + } + assert!( + DesktopCapabilities::read_latest(vec![old; 101], &keys, &report.community).is_err() + ); + } +} diff --git a/crates/buzz-core/src/desktop_lifecycle.rs b/crates/buzz-core/src/desktop_lifecycle.rs new file mode 100644 index 00000000000..35debdc67f9 --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle.rs @@ -0,0 +1,327 @@ +//! Owner-private lifecycle requests. Signed order is intent, not process state. +mod configuration; +use crate::{ + desktop_stop::{hex, read, sign, StopTarget}, + kind::{KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT}, +}; +pub use configuration::{RuntimeConfigurationRef, RuntimeConfigurationSummary}; +use nostr::{Event, Keys, Tag}; +use serde::{Deserialize, Serialize}; + +/// Start chooses a destination. Restart is a current-host-only one-shot. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Explicit ensure-running, without a remote reachability gate. + Start, + /// Ordinary Stop then one fresh launch, only on the resolved current host. + Restart, + /// Read actual local process status; never starts or stops anything. + Status, + /// Read a bounded page of destination-local configuration summaries. + Catalog, + /// Check exact launch prerequisites without changing placement or processes. + Preflight, +} + +/// Immutable request; retries retain its exact signed bytes. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Request { + /// Existing owner/community/agent/Desktop target shape. + pub target: StopTarget, + /// Requested operation, never shell text or configuration. + pub action: Action, + /// Restart's fresh successful Status request ID. None for other actions. + pub observed: Option, + /// Explicit next-launch revision. Missing legacy refs never authorize new launch. + #[serde(default)] + pub configuration: Option, + /// Exclusive catalog cursor; never a launch target or placement intent. + #[serde(default)] + pub cursor: Option, +} + +/// No credentials, paths, PIDs or raw runtime errors on the wire. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// Ordinary process registration/actual status confirms running locally. + Running, + /// Actual status confirms no managed process at this target. + Stopped, + /// Legacy receiver refusal, retained for wire compatibility. New receivers + /// use destination-local credentials and report Ineligible when unavailable. + ProvisioningUnavailable, + /// Runtime/readiness/ownership rejected the request. + Failed, + /// Superseded, interrupted, evicted or uncertain; never success. + Unknown, + /// Fresh, positive read-only catalog/preflight response; not launch authority. + Ready, + /// Exact configuration is absent, stale, or not currently launchable. + Ineligible, + /// A process exists, but it did not launch the requested configuration. + DifferentConfiguration, +} + +/// One summary per response keeps encrypted payloads below the existing 4096-byte bound. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CatalogPage { + /// None is an authoritative empty final page, not an unavailable host. + pub entry: Option, + /// Exclusive configuration ID cursor when more entries remain. + pub next: Option, +} + +/// Safe, short-lived observation. No launch plan, credentials or local paths. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Observation { + /// Unix seconds; bounded by the original request's timestamp plus 30 seconds. + pub valid_until: u64, + /// Launch-time identity, never inferred from the current selection. + pub running_configuration: Option, + /// Catalog data appears only on Catalog results. + pub catalog: Option, +} + +/// Signed Desktop outcome, not agent-signed termination proof. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ResultMessage { + /// Original immutable payload. + pub request: Request, + /// Original signed event identity. + pub id: String, + /// Local Desktop result. + pub outcome: Outcome, + /// Optional for legacy results, which cannot establish configuration readiness. + #[serde(default)] + pub observation: Option, +} + +/// Public envelope gate before persistence; content remains owner encrypted. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let kind = event.kind.as_u16() as u32; + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + let result = kind == KIND_DESKTOP_LIFECYCLE_RESULT; + if !matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) + || !(132..=4096).contains(&event.content.len()) + || tags.len() != if result { 2 } else { 1 } + || tags[0].len() != 2 + || tags[0][0] != "d" + || !hex(&tags[0][1], 32) + || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64))) + { + return Err("invalid Desktop lifecycle envelope"); + } + Ok(()) +} + +impl Request { + /// Validate target, action and correlation without inventing credentials. + pub fn validate(&self, community: &str) -> Result<(), String> { + self.target.validate(community)?; + if let Some(reference) = &self.configuration { + reference.validate()?; + } + if self + .cursor + .as_ref() + .is_some_and(|id| uuid::Uuid::parse_str(id).is_err()) + || (self.action != Action::Catalog && self.cursor.is_some()) + || (matches!(self.action, Action::Catalog | Action::Status) + && self.configuration.is_some()) + || (self.action == Action::Preflight && self.configuration.is_none()) + { + return Err("Invalid lifecycle configuration target".into()); + } + match (self.action, &self.observed) { + (Action::Restart, Some(id)) if hex(id, 64) => Ok(()), + (Action::Start | Action::Status | Action::Catalog | Action::Preflight, None) => Ok(()), + _ => Err("invalid Desktop lifecycle observation".into()), + } + } + /// Prepare once; retries must not create a new event/order. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate(&self.target.community)?; + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE, + vec![Tag::identifier(&self.target.desktop)], + ) + } + /// Authenticate owner, content, routing and captured community. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE)?; + value.validate(community)?; + if event.tags.identifier() != Some(value.target.desktop.as_str()) { + return Err("Desktop lifecycle routing mismatch".into()); + } + Ok(value) + } +} +impl ResultMessage { + fn validate_observation(&self, stamp: u64) -> Result<(), String> { + let fail = || "Invalid lifecycle observation".to_string(); + if self.outcome == Outcome::Ready + && (!matches!(self.request.action, Action::Catalog | Action::Preflight) + || self.observation.is_none()) + { + return Err(fail()); + } + if let Some(observation) = &self.observation { + if observation.valid_until <= stamp + || observation.valid_until > stamp.saturating_add(30) + { + return Err(fail()); + } + if let Some(reference) = &observation.running_configuration { + reference.validate()?; + if !matches!( + self.outcome, + Outcome::Running | Outcome::DifferentConfiguration + ) { + return Err(fail()); + } + } + if let Some(page) = &observation.catalog { + if self.request.action != Action::Catalog || self.outcome != Outcome::Ready { + return Err(fail()); + } + if let Some(entry) = &page.entry { + entry.validate(&self.request.target.desktop)?; + if self + .request + .cursor + .as_ref() + .is_some_and(|cursor| &entry.configuration.id <= cursor) + { + return Err(fail()); + } + } + if page.next.as_ref().is_some_and(|next| { + page.entry.as_ref().map(|e| &e.configuration.id) != Some(next) + }) { + return Err(fail()); + } + } else if self.request.action == Action::Catalog && self.outcome == Outcome::Ready { + return Err(fail()); + } + } + if self.outcome == Outcome::Running + && self.request.configuration.is_some() + && self + .observation + .as_ref() + .and_then(|o| o.running_configuration.as_ref()) + != self.request.configuration.as_ref() + { + return Err(fail()); + } + if matches!(self.request.action, Action::Catalog | Action::Preflight) + && matches!( + self.outcome, + Outcome::Running | Outcome::Stopped | Outcome::DifferentConfiguration + ) + { + return Err(fail()); + } + Ok(()) + } + /// Sign the actual Desktop result. It is immutable for this request. + pub fn sign(&self, keys: &Keys) -> Result { + self.request.validate(&self.request.target.community)?; + if !hex(&self.id, 64) { + return Err("invalid lifecycle request ID".into()); + } + sign( + self, + keys, + KIND_DESKTOP_LIFECYCLE_RESULT, + vec![ + Tag::identifier(&self.request.target.desktop), + Tag::parse(["e", &self.id]).map_err(|e| e.to_string())?, + ], + ) + } + /// Bind every correlation field to the original authenticated request. + pub fn read( + event: &Event, + keys: &Keys, + request: &Event, + community: &str, + ) -> Result { + let original = Request::read(request, keys, community)?; + let value: Self = read(event, keys, KIND_DESKTOP_LIFECYCLE_RESULT)?; + value.validate_observation(request.created_at.as_secs())?; + if value.request != original + || value.id != request.id.to_hex() + || event.tags.identifier() != Some(original.target.desktop.as_str()) + || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(value.id.as_str()) + { + return Err("Desktop lifecycle result mismatch".into()); + } + Ok(value) + } +} + +#[cfg(test)] +mod protocol_tests; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn scope_action_and_result_are_bound_to_one_signed_request() { + let keys = Keys::generate(); + let mut request = Request { + target: StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }, + action: Action::Start, + observed: None, + configuration: None, + cursor: None, + }; + let event = request.sign(&keys).unwrap(); + assert_eq!( + Request::read(&event, &keys, &request.target.community).unwrap(), + request + ); + assert!(Request::read(&event, &Keys::generate(), &request.target.community).is_err()); + assert!(Request::read(&event, &keys, "wss://other.example").is_err()); + assert!( + crate::desktop_stop::StopTarget::read(&event, &keys, &request.target.community) + .is_err() + ); + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::ProvisioningUnavailable, + observation: None, + } + .sign(&keys) + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &event, &request.target.community) + .unwrap() + .outcome, + Outcome::ProvisioningUnavailable + ); + let other = request.sign(&keys).unwrap(); + assert!(ResultMessage::read(&result, &keys, &other, &request.target.community).is_err()); + request.action = Action::Restart; + assert!(request.sign(&keys).is_err()); + request.observed = Some(event.id.to_hex()); + assert!(request.sign(&keys).is_ok()); + request.action = Action::Start; + assert!(request.sign(&keys).is_err()); + } +} diff --git a/crates/buzz-core/src/desktop_lifecycle/configuration.rs b/crates/buzz-core/src/desktop_lifecycle/configuration.rs new file mode 100644 index 00000000000..9ba560cd734 --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle/configuration.rs @@ -0,0 +1,62 @@ +//! Safe owner-private projections, not another configuration store or launch plan. +use serde::{Deserialize, Serialize}; + +/// Exact destination-local configuration version. Edits produce a new revision. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigurationRef { + /// Stable configuration identity within the targeted agent and host. + pub id: String, + /// Immutable revision selected by the requester, never a latest alias. + pub revision: String, +} +impl RuntimeConfigurationRef { + /// Reject malformed references before resolving any local settings. + pub fn validate(&self) -> Result<(), String> { + if uuid::Uuid::parse_str(&self.id).is_err() + || uuid::Uuid::parse_str(&self.revision).is_err() + { + return Err("Invalid runtime configuration reference".into()); + } + Ok(()) + } +} + +/// Explicit allowlist for discovery. Never serialize the underlying settings. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeConfigurationSummary { + /// Exact configuration version checked by the destination. + pub configuration: RuntimeConfigurationRef, + /// User-authored display name. + pub name: String, + /// Destination Desktop ID, not a hostname or filesystem path. + pub host: String, + /// Runtime catalog identifier. + pub runtime: String, + /// Deliberately selected model identifier. + pub model: String, + /// Deliberately selected provider identifier, never credentials. + pub provider: Option, + /// Positive destination-local readiness; unknown must be false. + pub eligible: bool, +} +impl RuntimeConfigurationSummary { + /// Bound untrusted display metadata and bind it to the probed host. + pub fn validate(&self, host: &str) -> Result<(), String> { + self.configuration.validate()?; + let text = |value: &str, max| { + !value.trim().is_empty() && value.len() <= max && !value.chars().any(char::is_control) + }; + if self.host != host + || !super::hex(host, 32) + || !text(&self.name, 120) + || !text(&self.runtime, 128) + || !text(&self.model, 512) + || self.provider.as_ref().is_some_and(|p| !text(p, 128)) + { + return Err("Invalid runtime configuration summary".into()); + } + Ok(()) + } +} diff --git a/crates/buzz-core/src/desktop_lifecycle/protocol_tests.rs b/crates/buzz-core/src/desktop_lifecycle/protocol_tests.rs new file mode 100644 index 00000000000..beb1fba5ff6 --- /dev/null +++ b/crates/buzz-core/src/desktop_lifecycle/protocol_tests.rs @@ -0,0 +1,158 @@ +use super::*; +fn reference() -> RuntimeConfigurationRef { + RuntimeConfigurationRef { + id: uuid::Uuid::new_v4().to_string(), + revision: uuid::Uuid::new_v4().to_string(), + } +} +fn request(action: Action) -> Request { + Request { + target: StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }, + action, + observed: None, + configuration: (action == Action::Preflight).then(reference), + cursor: None, + } +} +#[test] +fn encrypted_catalog_scope_cursor_expiry_and_payload_bound() { + let keys = Keys::generate(); + let request = request(Action::Catalog); + let event = request.sign(&keys).unwrap(); + let entry = RuntimeConfigurationSummary { + configuration: reference(), + name: "n".repeat(120), + host: request.target.desktop.clone(), + runtime: "r".repeat(128), + model: "m".repeat(512), + provider: Some("p".repeat(128)), + eligible: true, + }; + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::Ready, + observation: Some(Observation { + valid_until: event.created_at.as_secs() + 30, + running_configuration: None, + catalog: Some(CatalogPage { + next: Some(entry.configuration.id.clone()), + entry: Some(entry), + }), + }), + }; + let signed = result.sign(&keys).unwrap(); + assert!(signed.content.len() <= 4096); + assert_eq!( + ResultMessage::read(&signed, &keys, &event, &request.target.community).unwrap(), + result + ); + assert!(ResultMessage::read( + &signed, + &Keys::generate(), + &event, + &request.target.community + ) + .is_err()); + assert!(ResultMessage::read(&signed, &keys, &event, "wss://other.example").is_err()); + for mutation in 0..6 { + let mut invalid = result.clone(); + let observation = invalid.observation.as_mut().unwrap(); + match mutation { + 0 => observation.valid_until += 1, + 1 => observation.valid_until = event.created_at.as_secs(), + 2 => { + observation + .catalog + .as_mut() + .unwrap() + .entry + .as_mut() + .unwrap() + .host = "b".repeat(32) + } + 3 => observation.catalog.as_mut().unwrap().next = Some(reference().id), + 4 => observation.catalog = None, + _ => { + invalid.request.cursor = invalid + .observation + .as_ref() + .unwrap() + .catalog + .as_ref() + .unwrap() + .next + .clone() + } + } + let original = invalid.request.sign(&keys).unwrap(); + let original = nostr::EventBuilder::new(original.kind, original.content) + .tags(original.tags) + .custom_created_at(event.created_at) + .sign_with_keys(&keys) + .unwrap(); + invalid.id = original.id.to_hex(); + assert!(ResultMessage::read( + &invalid.sign(&keys).unwrap(), + &keys, + &original, + &request.target.community, + ) + .is_err()); + } +} +#[test] +fn exact_ref_is_bound_and_unknown_running_never_satisfies_start() { + let keys = Keys::generate(); + let mut request = request(Action::Preflight); + let event = request.sign(&keys).unwrap(); + let mut result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::Ready, + observation: Some(Observation { + valid_until: event.created_at.as_secs() + 30, + running_configuration: None, + catalog: None, + }), + }; + result.request.configuration = Some(reference()); + assert!(ResultMessage::read( + &result.sign(&keys).unwrap(), + &keys, + &event, + &request.target.community + ) + .is_err()); + request.action = Action::Start; + let start = request.sign(&keys).unwrap(); + result.id = start.id.to_hex(); + result.request = request; + result.outcome = Outcome::Running; + assert!(ResultMessage::read( + &result.sign(&keys).unwrap(), + &keys, + &start, + &result.request.target.community + ) + .is_err()); + result.observation.as_mut().unwrap().running_configuration = + result.request.configuration.clone(); + assert!(ResultMessage::read( + &result.sign(&keys).unwrap(), + &keys, + &start, + &result.request.target.community + ) + .is_ok()); + result.request.cursor = Some(reference().id); + assert!(result.request.sign(&keys).is_err()); + result.request.cursor = None; + result.request.configuration.as_mut().unwrap().revision = "latest".into(); + assert!(result.request.sign(&keys).is_err()); +} diff --git a/crates/buzz-core/src/desktop_observation.rs b/crates/buzz-core/src/desktop_observation.rs new file mode 100644 index 00000000000..21ba206280a --- /dev/null +++ b/crates/buzz-core/src/desktop_observation.rs @@ -0,0 +1,123 @@ +//! Owner-private, advisory Desktop observations; never agent readiness or placement. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_OBSERVATION}; + +/// A pulse for one local profile. The signed event timestamp is the observed time. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopObservation { + /// Format version. + pub v: u8, + /// Canonical community, encrypted along with the coordinate. + pub community: String, + /// Stable Desktop profile coordinate, not an execution credential. + pub id: String, +} + +/// Validate the bounded public envelope without decrypting it. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_OBSERVATION) +} + +impl DesktopObservation { + /// Observe a profile belonging to this local Desktop. + pub fn new(profile: DesktopProfile) -> Self { + Self { + v: 1, + community: profile.community, + id: profile.id, + } + } + + /// Encrypt and sign a fresh observation, without rewriting the durable profile. + pub fn sign(&self, keys: &Keys) -> Result { + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(KIND_DESKTOP_OBSERVATION as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) + } + + /// Authenticate and decrypt an observation before displaying its timestamp. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop observation signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop observation".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop observation decryption failed")?; + let observation: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop observation")?; + let expected = Self::new(DesktopProfile::new( + community.to_owned(), + event.tags.identifier().unwrap_or_default().to_owned(), + )?); + if observation != expected { + return Err("Desktop observation scope mismatch".into()); + } + Ok(observation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn observation_is_private_scoped_and_distinct_from_profile() { + let keys = Keys::generate(); + let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(); + let saved = profile.sign(&keys).unwrap(); + let observation = DesktopObservation::new(profile); + let event = observation.sign(&keys).unwrap(); + assert_eq!( + DesktopObservation::read(&event, &keys, &observation.community).unwrap(), + observation + ); + assert!(DesktopObservation::read(&event, &keys, "wss://two.example").is_err()); + assert!( + DesktopObservation::read(&event, &Keys::generate(), &observation.community).is_err() + ); + assert!(DesktopObservation::read(&saved, &keys, &observation.community).is_err()); + assert!(DesktopProfile::read(&event, &keys, &observation.community).is_err()); + let forged_author = EventBuilder::new(event.kind, &event.content) + .tags(event.tags.clone()) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(DesktopObservation::read(&forged_author, &keys, &observation.community).is_err()); + let mut tampered = event.clone(); + tampered.created_at = nostr::Timestamp::from(1); + assert!(DesktopObservation::read(&tampered, &keys, &observation.community).is_err()); + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_OBSERVATION)); + for field in ["v", "community", "id", "extra"] { + let mut payload = serde_json::to_value(&observation).unwrap(); + payload[field] = serde_json::json!("invalid"); + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, ciphertext) + .tags(event.tags.clone()) + .sign_with_keys(&keys) + .unwrap(); + assert!( + DesktopObservation::read(&invalid, &keys, &observation.community).is_err(), + "{field}" + ); + } + } +} diff --git a/crates/buzz-core/src/desktop_profile.rs b/crates/buzz-core/src/desktop_profile.rs new file mode 100644 index 00000000000..5b08a438b3a --- /dev/null +++ b/crates/buzz-core/src/desktop_profile.rs @@ -0,0 +1,159 @@ +//! Owner-private display-only Desktop identity; never execution authority. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::kind::KIND_DESKTOP_PROFILE; + +/// Minimal encrypted profile. IDs are installation-local within an owner/community. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DesktopProfile { + /// Format version. + pub v: u8, + /// Canonical relay URL, bound inside the ciphertext. + pub community: String, + /// Opaque random coordinate, not an agent key. + pub id: String, + /// Generated display name, never a hostname. + pub name: String, +} + +/// Validate the public envelope without decrypting private content. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + validate_private_desktop_envelope(event, KIND_DESKTOP_PROFILE) +} + +pub(crate) fn validate_private_desktop_envelope( + event: &Event, + kind: u32, +) -> Result<(), &'static str> { + let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect(); + if event.kind.as_u16() as u32 != kind + || event.created_at.as_secs() > 253_402_300_799 + || !(132..=2048).contains(&event.content.len()) + || tags.len() != 1 + || tags[0].len() != 2 + || tags[0][0] != "d" + || !valid_id(&tags[0][1]) + { + return Err("invalid Desktop profile envelope"); + } + Ok(()) +} + +fn valid_id(id: &str) -> bool { + id.len() == 32 + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +impl DesktopProfile { + /// Construct a generated, non-identifying name for a random coordinate. + pub fn new(community: String, id: String) -> Result { + if !valid_id(&id) || community.is_empty() || community.len() > 512 { + return Err("invalid Desktop profile coordinate"); + } + Ok(Self { + v: 1, + name: format!("Desktop {}", &id[..8]), + community, + id, + }) + } + + /// Encrypt to the owner and sign the exact replaceable event. + pub fn sign(&self, keys: &Keys) -> Result { + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(self).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(KIND_DESKTOP_PROFILE as u16), content) + .tag(Tag::identifier(&self.id)) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) + } + + /// Verify hash/signature, owner, scope and exact payload before display. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + validate_envelope(event)?; + event + .verify() + .map_err(|_| "invalid Desktop profile signature")?; + if event.pubkey != keys.public_key() { + return Err("foreign Desktop profile".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop profile decryption failed")?; + let profile: Self = + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop profile")?; + if profile + != Self::new( + community.to_owned(), + event.tags.identifier().unwrap_or_default().to_owned(), + )? + { + return Err("invalid Desktop profile payload or community".into()); + } + Ok(profile) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_profile_roundtrip_and_untrusted_inputs() { + let owner = Keys::generate(); + let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap(); + let event = profile.sign(&owner).unwrap(); + assert_eq!( + DesktopProfile::read(&event, &owner, &profile.community).unwrap(), + profile + ); + assert!(!event.content.contains(&profile.name)); + assert!(DesktopProfile::read(&event, &Keys::generate(), &profile.community).is_err()); + assert!(DesktopProfile::read(&event, &owner, "wss://two.example").is_err()); + let mut tampered = event.clone(); + tampered.content.push('x'); + assert!(DesktopProfile::read(&tampered, &owner, &profile.community).is_err()); + for field in ["v", "id", "name", "community", "extra"] { + let mut payload = serde_json::to_value(&profile).unwrap(); + payload[field] = serde_json::json!("wrong"); + let encrypted = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + payload.to_string(), + nip44::Version::V2, + ) + .unwrap(); + let invalid = EventBuilder::new(event.kind, encrypted) + .tag(Tag::identifier(&profile.id)) + .sign_with_keys(&owner) + .unwrap(); + assert!( + DesktopProfile::read(&invalid, &owner, &profile.community).is_err(), + "{field}" + ); + } + for tags in [ + vec![], + vec![Tag::identifier("bad")], + vec![Tag::identifier(&profile.id), Tag::identifier(&profile.id)], + ] { + let invalid = EventBuilder::new(event.kind, &event.content) + .tags(tags) + .sign_with_keys(&owner) + .unwrap(); + assert!(validate_envelope(&invalid).is_err()); + } + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_PROFILE)); + assert!(crate::kind::is_parameterized_replaceable( + KIND_DESKTOP_PROFILE + )); + } +} diff --git a/crates/buzz-core/src/desktop_stop.rs b/crates/buzz-core/src/desktop_stop.rs new file mode 100644 index 00000000000..9291c323c8b --- /dev/null +++ b/crates/buzz-core/src/desktop_stop.rs @@ -0,0 +1,229 @@ +//! Immutable, owner-to-self Desktop Stop messages. Profiles are not authority. +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::kind::{KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT}; + +/// One agent on one Desktop in one community; never a caller-selected process. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct StopTarget { + /// Schema version. Old exact-run commands are not accepted here. + pub v: u8, + /// Canonical community WebSocket URL. + pub community: String, + /// Installation coordinate from the private Desktop inventory. + pub desktop: String, + /// Agent public key. The receiver independently verifies local ownership. + pub agent: String, +} + +/// Ordinary Desktop outcome, not a stronger process-termination certificate. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StopOutcome { + /// Ordinary Stop returned success. + Stopped, + /// Ordinary Stop returned an error. No automatic retry of the effect. + Failed, + /// Interrupted, stale or evicted request; never inferred success. + Unknown, +} + +/// Correlates exactly one immutable request with its Desktop's result. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct StopResult { + /// Original target, not mutable current routing. + pub target: StopTarget, + /// Signed request event ID. + pub request: String, + /// No diagnostic paths, credentials or process details on the wire. + pub outcome: StopOutcome, +} + +pub(crate) fn hex(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Check public shape before storage, without decrypting content. +pub fn validate_envelope(event: &Event) -> Result<(), &'static str> { + let kind = event.kind.as_u16() as u32; + let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect(); + let result = kind == KIND_DESKTOP_STOP_RESULT; + if !matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) + || !(132..=4096).contains(&event.content.len()) + || tags.len() != if result { 2 } else { 1 } + || tags[0].len() != 2 + || tags[0][0] != "d" + || !hex(&tags[0][1], 32) + || (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64))) + { + return Err("invalid Desktop Stop envelope"); + } + Ok(()) +} + +pub(crate) fn sign( + value: &T, + keys: &Keys, + kind: u32, + tags: Vec, +) -> Result { + let ciphertext = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + serde_json::to_string(value).map_err(|e| e.to_string())?, + nip44::Version::V2, + ) + .map_err(|e| e.to_string())?; + EventBuilder::new(Kind::Custom(kind as u16), ciphertext) + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) +} + +pub(crate) fn read( + event: &Event, + keys: &Keys, + kind: u32, +) -> Result { + if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + validate_envelope(event)?; + } else { + crate::desktop_lifecycle::validate_envelope(event)?; + } + event + .verify() + .map_err(|_| "invalid Desktop Stop signature")?; + if event.pubkey != keys.public_key() || event.kind.as_u16() as u32 != kind { + return Err("foreign Desktop Stop message".into()); + } + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|_| "Desktop Stop decryption failed")?; + serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop Stop payload".into()) +} + +impl StopTarget { + /// Validate the decrypted target against the captured community. + pub fn validate(&self, community: &str) -> Result<(), &'static str> { + if self.v != 1 + || self.community != community + || community.is_empty() + || community.len() > 512 + || !hex(&self.desktop, 32) + || !hex(&self.agent, 64) + || PublicKey::from_hex(&self.agent).is_err() + { + return Err("invalid Desktop Stop target"); + } + Ok(()) + } + + /// Produce a new immutable Stop. Transport retries must reuse this event. + pub fn sign(&self, keys: &Keys) -> Result { + self.validate(&self.community)?; + sign( + self, + keys, + KIND_DESKTOP_STOP, + vec![Tag::identifier(&self.desktop)], + ) + } + + /// Authenticate, decrypt and bind a Stop to its signed host coordinate. + pub fn read(event: &Event, keys: &Keys, community: &str) -> Result { + let target: Self = read(event, keys, KIND_DESKTOP_STOP)?; + target.validate(community)?; + if event.tags.identifier() != Some(target.desktop.as_str()) { + return Err("Desktop Stop routing mismatch".into()); + } + Ok(target) + } +} + +impl StopResult { + /// Sign the saved ordinary Stop result without exposing local diagnostics. + pub fn sign(&self, keys: &Keys) -> Result { + self.target.validate(&self.target.community)?; + if !hex(&self.request, 64) { + return Err("invalid Stop request ID".into()); + } + sign( + self, + keys, + KIND_DESKTOP_STOP_RESULT, + vec![ + Tag::identifier(&self.target.desktop), + Tag::parse(["e", &self.request]).map_err(|e| e.to_string())?, + ], + ) + } + + /// Check all correlation fields against the original authenticated request. + pub fn read( + event: &Event, + keys: &Keys, + request: &Event, + community: &str, + ) -> Result { + let target = StopTarget::read(request, keys, community)?; + let result: Self = read(event, keys, KIND_DESKTOP_STOP_RESULT)?; + if result.target != target + || result.request != request.id.to_hex() + || event.tags.identifier() != Some(target.desktop.as_str()) + || event.tags.iter().nth(1).and_then(|t| t.content()) != Some(result.request.as_str()) + { + return Err("Desktop Stop result correlation mismatch".into()); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_immutable_stop_and_exact_result_correlation() { + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let request = target.sign(&keys).unwrap(); + assert_eq!( + StopTarget::read(&request, &keys, &target.community).unwrap(), + target + ); + assert!(!request.content.contains(&target.agent)); + assert!(StopTarget::read(&request, &Keys::generate(), &target.community).is_err()); + assert!(StopTarget::read(&request, &keys, "wss://other.example").is_err()); + let result = StopResult { + target: target.clone(), + request: request.id.to_hex(), + outcome: StopOutcome::Stopped, + } + .sign(&keys) + .unwrap(); + assert_eq!( + StopResult::read(&result, &keys, &request, &target.community) + .unwrap() + .outcome, + StopOutcome::Stopped + ); + let another = target.sign(&keys).unwrap(); + assert!(StopResult::read(&result, &keys, &another, &target.community).is_err()); + let mut tampered = request.clone(); + tampered.content.push('x'); + assert!(StopTarget::read(&tampered, &keys, &target.community).is_err()); + for kind in [KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT] { + assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&kind)); + assert!(!crate::kind::is_parameterized_replaceable(kind)); + } + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5e..3146c511a28 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -117,6 +117,23 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; +/// Owner-private encrypted Desktop identity/name, keyed by installation coordinate. +pub const KIND_DESKTOP_PROFILE: u32 = 30180; + +/// Owner-private, per-Desktop last-heard observation; not online or readiness. +pub const KIND_DESKTOP_OBSERVATION: u32 = 30181; +/// Owner-private built-in runtime facts per Desktop, not agent readiness. +pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182; + +/// Immutable owner-private Desktop Stop request (not a replaceable profile). +pub const KIND_DESKTOP_STOP: u32 = 50180; +/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID. +pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181; +/// Owner-private Start/Restart/status request, separate from legacy Stop. +pub const KIND_DESKTOP_LIFECYCLE: u32 = 50182; +/// Correlated owner-private Desktop lifecycle result. +pub const KIND_DESKTOP_LIFECYCLE_RESULT: u32 = 50183; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -130,6 +147,13 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_EVENT_REMINDER, KIND_PUSH_LEASE, KIND_PRIVATE_MANAGED_AGENT, + KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, ]; /// Kinds that require a result-level read gate beyond the filter-layer @@ -659,6 +683,13 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, + KIND_DESKTOP_PROFILE, + KIND_DESKTOP_OBSERVATION, + KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, + KIND_DESKTOP_LIFECYCLE, + KIND_DESKTOP_LIFECYCLE_RESULT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3b..d80446ac35e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,12 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +pub mod desktop_capabilities; +pub mod desktop_lifecycle; +pub mod desktop_observation; +/// Owner-private Desktop display profiles. +pub mod desktop_profile; +pub mod desktop_stop; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs index 77c74a069ad..ba14dc87db0 100644 --- a/crates/buzz-core/src/relay.rs +++ b/crates/buzz-core/src/relay.rs @@ -1,4 +1,4 @@ -//! Canonical relay identities shared by runtime components. +//! Legacy canonical relay identities shared by compatibility consumers. use thiserror::Error; use url::{Host, Url}; @@ -23,17 +23,16 @@ pub enum NormalizeRelayUrlError { MissingHost, } -/// Canonicalize a WebSocket relay URL for use as a runtime identity key. +/// Canonicalize a WebSocket relay URL for legacy equivalence consumers. /// -/// This is the sole normalizer for `(agent, relay)` process identity. It keeps -/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to -/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root -/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison -/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower -/// equivalence rules and must not be widened by runtime-key canonicalization. +/// Bestie scope and pollen/profile migration retain this historical behavior: +/// keep the WebSocket scheme, lowercase DNS hosts, fold all loopback spellings +/// to `127.0.0.1`, remove default ports and trailing slashes, and preserve +/// queries. Managed-agent process identity intentionally uses a scoped +/// host-preserving normalizer because relay hosts are tenant authorities. /// -/// Connection code may retain the configured URL; this canonical form is for -/// identity, receipts, status and deduplication. +/// This deliberately is **not** the NIP-42 AUTH comparison helper in +/// `buzz-auth`; changing either equivalence contract requires a separate review. pub fn normalize_relay_url(raw: &str) -> Result { let mut url = Url::parse(raw.trim()) .map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 59015125042..8d2bf6ea697 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -702,7 +702,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 44); + assert_eq!(migrations.len(), 49); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -910,8 +910,9 @@ mod postgres_tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); - assert!(include_str!("../../../../schema/schema.sql") - .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); + assert!(include_str!("../../../../schema/schema.sql").contains( + "kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183)" + )); // Public push-gateway authority is intentionally deployment-global and // durable: immediate revocation and hostile-relay admission cannot be @@ -2386,7 +2387,18 @@ mod postgres_tests { .await .expect("insert community"); - for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32), (3_u8, 30_179_i32)] { + for (marker, kind) in [ + (1_u8, 1_i32), + (2_u8, 30_350_i32), + (3_u8, 30_179_i32), + (4_u8, 30_180_i32), + (5_u8, 30_181_i32), + (6_u8, 30_182_i32), + (7_u8, 50_180_i32), + (8_u8, 50_181_i32), + (9_u8, 50_182_i32), + (10_u8, 50_183_i32), + ] { sqlx::query( "INSERT INTO events \ (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \ @@ -2413,7 +2425,21 @@ mod postgres_tests { .fetch_all(&pool) .await .expect("read pre-push search behavior"); - assert_eq!(before, vec![(1, true), (30_179, true), (30_350, true)]); + assert_eq!( + before, + vec![ + (1, true), + (30_179, true), + (30_180, true), + (30_181, true), + (30_182, true), + (30_350, true), + (50_180, true), + (50_181, true), + (50_182, true), + (50_183, true) + ] + ); // 0014 fixes 30350 only. A brownfield database that stopped here still // tokenized kind:30179 ciphertext — the gap 0033 closes. @@ -2430,7 +2456,73 @@ mod postgres_tests { .expect("read pre-0033 search behavior"); assert_eq!( pre_0033, - vec![(1, Some(true)), (30_179, Some(true)), (30_350, None)] + vec![ + (1, Some(true)), + (30_179, Some(true)), + (30_180, Some(true)), + (30_181, Some(true)), + (30_182, Some(true)), + (30_350, None), + (50_180, Some(true)), + (50_181, Some(true)), + (50_182, Some(true)), + (50_183, Some(true)) + ] + ); + + run_migrations_through(&pool, 44) + .await + .expect("apply through 44"); + let desktop_indexed: bool = sqlx::query_scalar( + "SELECT search_tsv @@ plainto_tsquery('simple', 'needle') \ + FROM events WHERE kind = 30180", + ) + .fetch_one(&pool) + .await + .expect("read pre-0045 Desktop search behavior"); + assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS"); + run_migrations_through(&pool, 45) + .await + .expect("apply through 45"); + let observation_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30181") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + observation_indexed, + "0046 must change brownfield observation FTS" + ); + + run_migrations_through(&pool, 46).await.unwrap(); + let capability_indexed: bool = + sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30182") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + capability_indexed, + "0047 must change brownfield capability FTS" + ); + + run_migrations_through(&pool, 47).await.unwrap(); + let stop_indexed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind IN (50180, 50181) AND search_tsv IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS"); + run_migrations_through(&pool, 48).await.unwrap(); + let lifecycle_indexed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE kind IN (50182, 50183) AND search_tsv IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + lifecycle_indexed, 2, + "0049 must change populated lifecycle FTS" ); run_migrations(&pool) @@ -2443,7 +2535,29 @@ mod postgres_tests { .fetch_all(&pool) .await .expect("read post-upgrade search behavior"); - assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]); + assert_eq!( + after, + vec![ + (1, Some(true)), + (30_179, None), + (30_180, None), + (30_181, None), + (30_182, None), + (30_350, None), + (50_180, None), + (50_181, None), + (50_182, None), + (50_183, None) + ] + ); + let gin_exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = 'events' \ + AND indexname = 'idx_events_search_tsv' AND indexdef LIKE '%USING gin%')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(gin_exists, "upgrade must restore the search GIN index"); } #[tokio::test] diff --git a/crates/buzz-db/src/runtime/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs index 044dc3a58c6..0294d4f186a 100644 --- a/crates/buzz-db/src/runtime/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -544,12 +544,69 @@ pub enum ProbeError { MaskedActivity { /// Number of other client backends with masked/unknown state. masked: i64, + /// Test-only bounded facts from the refused aggregate, never raw labels. + #[cfg(test)] + diagnostic: serde_json::Value, }, /// The single heartbeat row (migration 0026) is missing on the writer. #[error("replica_heartbeat row missing on the writer — migration 0026 not applied?")] HeartbeatRowMissing, } +const ACTIVITY_SQL: &str = r#" + SELECT + least( + (SELECT min(xact_start) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid()), + (SELECT min(prepared) FROM pg_prepared_xacts) + ) AS oldest_xact_start, + (SELECT count(*) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND (backend_type IS NULL + OR (backend_type = 'client backend' + AND (state IS NULL + OR (state <> 'idle' AND xact_start IS NULL)))) + ) AS masked + "#; + +// Only the aggregate projection changes in test builds: count and one bounded, +// label-free representative consume the SAME filtered rows, not a second scan. +// A single statement alone would not establish that for arbitrary PG statistics +// functions. Here both aggregates share one input from pg_stat_activity, so a +// backend disappearing later cannot replace the refused sample. min(text) keeps +// one fixed-width tuple, rather than collecting every backend into an array. +#[cfg(test)] +const MASKED_DIAGNOSTIC_AGGREGATE: &str = r#"jsonb_build_object( + 'count', count(*), + 'probe_pid', pg_backend_pid(), + 'read_all_stats', pg_has_role(current_user, 'pg_read_all_stats', 'USAGE'), + 'representative', min(jsonb_build_object( + 'pid', pid, + 'same_database', datname = current_database(), + 'same_role', usesysid = current_user::regrole::oid, + 'backend', CASE WHEN backend_type IS NULL THEN 'null' + WHEN backend_type = 'client backend' THEN 'client' + ELSE 'other' END, + 'state', CASE WHEN state IS NULL THEN 'null' + WHEN state = 'idle' THEN 'idle' + WHEN state = 'active' THEN 'active' + WHEN state = 'idle in transaction' THEN 'idle in transaction' + WHEN state = 'idle in transaction (aborted)' THEN 'aborted' + WHEN state = 'disabled' THEN 'disabled' + ELSE 'other' END, + 'xact_start', xact_start, + 'backend_start', backend_start, + 'state_change', state_change + )::text)::jsonb +)"#; + +#[cfg(test)] +fn diagnostic_activity_sql() -> String { + ACTIVITY_SQL.replacen("count(*)", MASKED_DIAGNOSTIC_AGGREGATE, 1) +} + /// Take one ordered writer sample: S, then activity scan, then commit the /// heartbeat token **last**. /// @@ -593,30 +650,24 @@ async fn sample_writer(writer: &PgPool) -> Result { // after the token. Their deferred floor guard already ran at PREPARE, // so `pg_prepared_xacts.prepared` bounds their rows exactly like // `xact_start`; fold it into the same minimum. - let row = sqlx::query( - r#" - SELECT - least( - (SELECT min(xact_start) - FROM pg_stat_activity - WHERE pid <> pg_backend_pid()), - (SELECT min(prepared) FROM pg_prepared_xacts) - ) AS oldest_xact_start, - (SELECT count(*) - FROM pg_stat_activity - WHERE pid <> pg_backend_pid() - AND (backend_type IS NULL - OR (backend_type = 'client backend' - AND (state IS NULL - OR (state <> 'idle' AND xact_start IS NULL)))) - ) AS masked - "#, - ) - .fetch_one(&mut *conn) - .await?; + #[cfg(not(test))] + let query = sqlx::query(ACTIVITY_SQL); + // Only two compile-time literals enter this test-only query builder. + #[cfg(test)] + let query = sqlx::query(sqlx::AssertSqlSafe(diagnostic_activity_sql())); + let row = query.fetch_one(&mut *conn).await?; + #[cfg(not(test))] let masked: i64 = row.get("masked"); + #[cfg(test)] + let diagnostic: serde_json::Value = row.get("masked"); + #[cfg(test)] + let masked = diagnostic["count"].as_i64().expect("count(*) is an i64"); if masked > 0 { - return Err(ProbeError::MaskedActivity { masked }); + return Err(ProbeError::MaskedActivity { + masked, + #[cfg(test)] + diagnostic, + }); } let oldest_xact_start: Option> = row.get("oldest_xact_start"); @@ -802,6 +853,61 @@ pub async fn run_probe(writer: PgPool, fence: Arc) { } } +#[cfg(test)] +mod diagnostic_tests { + use super::*; + use sha2::{Digest, Sha256}; + + #[test] + fn diagnostic_changes_only_the_aggregate_not_the_classified_set() { + // Frozen pre-diagnostic SQL, including whitespace: catches changes to + // the production predicate, other-PID scope, and prepared-xact minimum. + assert_eq!( + hex::encode(Sha256::digest(ACTIVITY_SQL.as_bytes())), + "a11e60f4316a81b8d00809140ed2e52613c3f96c22020eb0fa5c2abb4c25b380" + ); + assert_eq!(ACTIVITY_SQL.matches("count(*)").count(), 1); + let instrumented = diagnostic_activity_sql(); + // Full equality, not a substring test: undoing the single aggregate + // projection must restore every byte of the actual production query. + assert_eq!( + instrumented.replace(MASKED_DIAGNOSTIC_AGGREGATE, "count(*)"), + ACTIVITY_SQL + ); + assert_eq!(instrumented.matches("count(*)").count(), 1); + } + + #[test] + fn diagnostic_projection_is_a_bounded_data_allowlist() { + // Review the complete projection, not a blacklist of possible secrets. + // In particular, database/role identities are comparisons only, state + // and backend type map to literals, and min retains one WHOLE tuple. + let approved = r#"jsonb_build_object( + 'count', count(*), + 'probe_pid', pg_backend_pid(), + 'read_all_stats', pg_has_role(current_user, 'pg_read_all_stats', 'USAGE'), + 'representative', min(jsonb_build_object( + 'pid', pid, + 'same_database', datname = current_database(), + 'same_role', usesysid = current_user::regrole::oid, + 'backend', CASE WHEN backend_type IS NULL THEN 'null' + WHEN backend_type = 'client backend' THEN 'client' ELSE 'other' END, + 'state', CASE WHEN state IS NULL THEN 'null' + WHEN state = 'idle' THEN 'idle' + WHEN state = 'active' THEN 'active' + WHEN state = 'idle in transaction' THEN 'idle in transaction' + WHEN state = 'idle in transaction (aborted)' THEN 'aborted' + WHEN state = 'disabled' THEN 'disabled' ELSE 'other' END, + 'xact_start', xact_start, + 'backend_start', backend_start, + 'state_change', state_change + )::text)::jsonb + )"#; + let tokens = |sql: &str| sql.split_whitespace().collect::>().join(" "); + assert_eq!(tokens(MASKED_DIAGNOSTIC_AGGREGATE), tokens(approved)); + } +} + #[cfg(test)] mod postgres_tests { use super::*; @@ -1072,10 +1178,17 @@ mod postgres_tests { .await .expect_err("masked pg_stat_activity must fail closed"); assert!( - matches!(err, ProbeError::MaskedActivity { masked } if masked >= 1), + matches!(err, ProbeError::MaskedActivity { masked, .. } if masked >= 1), "expected MaskedActivity, got {err:?}" ); + if let ProbeError::MaskedActivity { masked, diagnostic } = &err { + assert_eq!(diagnostic["count"].as_i64(), Some(*masked)); + assert_eq!(diagnostic["read_all_stats"], false); + assert!(diagnostic["representative"]["pid"].is_i64()); + assert_ne!(diagnostic["representative"]["pid"], diagnostic["probe_pid"]); + } + tx.rollback().await.expect("rollback"); unpriv.close().await; sqlx::query(sqlx::AssertSqlSafe(format!("DROP ROLE {role}"))) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610de..816673219ef 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3839,7 +3839,7 @@ mod postgres_tests { /// - Redis pool points at the local dev instance for the admission check. /// /// Returns `None` when local Postgres is not reachable. - async fn bridge_handler_test_state() -> Option> { + pub(super) async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. @@ -4239,3 +4239,7 @@ mod postgres_tests { ); } } + +#[cfg(test)] +#[path = "desktop_profile_postgres_tests.rs"] +mod desktop_profile_postgres_tests; diff --git a/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs new file mode 100644 index 00000000000..9fcdb079277 --- /dev/null +++ b/crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs @@ -0,0 +1,465 @@ +//! Private Desktop profiles through the production HTTP and WebSocket paths. +use super::postgres_tests::bridge_handler_test_state; +use super::*; +use axum::{body::Body, http::Request}; +use buzz_core::kind::{ + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, +}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::json; +use tower::ServiceExt; + +async fn post( + state: &Arc, + host: &str, + path: &str, + keys: &Keys, + body: Value, + signed: bool, +) -> (StatusCode, Value) { + let mut request = Request::builder() + .method("POST") + .uri(path) + .header("host", host); + if signed { + let proof = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + request = request.header( + "authorization", + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&proof).unwrap()) + ), + ); + } else { + request = request.header("x-pubkey", keys.public_key().to_hex()); + } + let response = crate::router::build_router(state.clone()) + .oneshot( + request + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1_048_576) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut frames = vec![]; + while let Ok(frame) = rx.try_recv() { + let axum::extract::ws::Message::Text(text) = frame else { + panic!("text frame") + }; + frames.push(serde_json::from_str(&text).unwrap()); + } + frames +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_profile_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_PROFILE).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_observation_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_OBSERVATION).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_capabilities_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_CAPABILITIES).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_stop_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_STOP).await; + assert_private_desktop(KIND_DESKTOP_STOP_RESULT).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn desktop_lifecycle_authenticated_owner_query_and_private_storage() { + assert_private_desktop(KIND_DESKTOP_LIFECYCLE).await; + assert_private_desktop(KIND_DESKTOP_LIFECYCLE_RESULT).await; +} + +async fn assert_private_desktop(kind: u32) { + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("desktop-read-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let tenant = TenantContext::resolved(community, &host); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let profile = buzz_core::desktop_profile::DesktopProfile::new( + format!("wss://{host}"), + uuid::Uuid::new_v4().simple().to_string(), + ) + .unwrap(); + let id = profile.id.clone(); + let event = if matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + let target = buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: id.clone(), + agent: Keys::generate().public_key().to_hex(), + }; + let request = target.sign(&owner).unwrap(); + if kind == KIND_DESKTOP_STOP { + request + } else { + buzz_core::desktop_stop::StopResult { + target, + request: request.id.to_hex(), + outcome: buzz_core::desktop_stop::StopOutcome::Stopped, + } + .sign(&owner) + .unwrap() + } + } else if matches!(kind, KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT) { + let request = buzz_core::desktop_lifecycle::Request { + target: buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: id.clone(), + agent: Keys::generate().public_key().to_hex(), + }, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + configuration: None, + cursor: None, + }; + let event = request.sign(&owner).unwrap(); + if kind == KIND_DESKTOP_LIFECYCLE { + event + } else { + buzz_core::desktop_lifecycle::ResultMessage { + request, + id: event.id.to_hex(), + outcome: buzz_core::desktop_lifecycle::Outcome::Running, + observation: None, + } + .sign(&owner) + .unwrap() + } + } else if kind == KIND_DESKTOP_PROFILE { + profile.sign(&owner).unwrap() + } else if kind == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::DesktopCapabilities::new(profile, vec![]) + .sign(&owner) + .unwrap() + } else { + buzz_core::desktop_observation::DesktopObservation::new(profile) + .sign(&owner) + .unwrap() + }; + let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + // Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe. + let own = json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "limit":100}]); + let exact = + json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "#d":[id], "limit":1}]); + for filters in [&own, &exact] { + let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows.as_array().unwrap().len(), 1); + assert_eq!(rows[0]["id"], event.id.to_hex()); + let (status, result) = + post(&state, &host, "/query", &outsider, filters.clone(), true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + let (status, result) = post(&state, &host, "/query", &owner, filters.clone(), false).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}"); + } + // Known IDs cannot grant an authenticated outsider read access either. + let known = json!([{"ids":[event.id.to_hex()], "kinds":[kind,1]}]); + let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows, json!([])); + let other_host = format!("other-{host}"); + state + .db + .ensure_configured_community(&other_host) + .await + .unwrap(); + let (status, rows) = post(&state, &other_host, "/query", &owner, own.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows, json!([])); + // Inspect the generated column: searching for plaintext in ciphertext proves nothing. + let mut tx = state.db.begin_event_write_transaction().await.unwrap(); + let indexed: bool = sqlx::query_scalar( + "SELECT search_tsv IS NOT NULL FROM events WHERE id = $1 AND community_id = $2", + ) + .bind(event.id.to_bytes().as_slice()) + .bind(community.as_uuid()) + .fetch_one(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + assert!(!indexed, "private ciphertext must not enter FTS"); + // WS REQ is the frontend transport; bind its real authenticated owner path. + for who in [&owner, &outsider] { + let (mut conn, mut rx) = crate::connection::tests::test_conn_with_auth( + crate::connection::AuthState::Authenticated(buzz_auth::AuthContext { + pubkey: who.public_key(), + scopes: buzz_auth::Scope::all_known(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }), + ); + Arc::get_mut(&mut conn).unwrap().tenant = tenant.clone(); + crate::handlers::req::handle_req( + "desktops".into(), + serde_json::from_value(own.clone()).unwrap(), + vec![], + conn, + state.clone(), + ) + .await; + let frames = drain(&mut rx); + let rows: Vec<_> = frames.iter().filter(|frame| frame[0] == "EVENT").collect(); + if who.public_key() == owner.public_key() { + assert_eq!(rows.len(), 1, "{frames:?}"); + assert_eq!(rows[0][2]["id"], event.id.to_hex()); + assert!(frames.iter().any(|frame| frame[0] == "EOSE"), "{frames:?}"); + } else { + assert!(rows.is_empty(), "{frames:?}"); + assert!( + frames.iter().any(|frame| frame[0] == "CLOSED"), + "{frames:?}" + ); + } + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() { + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple()); + state.db.ensure_configured_community(&host).await.unwrap(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let profile = buzz_core::desktop_profile::DesktopProfile::new( + format!("wss://{host}"), + uuid::Uuid::new_v4().simple().to_string(), + ) + .unwrap(); + let prepared = profile.sign(&owner).unwrap(); + // Model bytes committed during yesterday's offline first launch. Neither + // the first submission nor its duplicate is re-dated or re-signed below. + let now = Timestamp::now().as_secs(); + let aged = EventBuilder::new(prepared.kind, &prepared.content) + .tags(prepared.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now - 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let raw = json!(aged); + for _ in 0..2 { + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true, "{result}"); + let (status, rows) = post( + &state, + &host, + "/query", + &owner, + json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{rows}"); + assert_eq!(rows.as_array().unwrap().len(), 1); + for field in [ + "id", + "pubkey", + "kind", + "created_at", + "tags", + "content", + "sig", + ] { + assert_eq!(rows[0][field], raw[field], "stored {field} changed"); + } + let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap(); + assert_eq!( + buzz_core::desktop_profile::DesktopProfile::read( + &stored, + &owner, + &format!("wss://{host}") + ) + .unwrap(), + profile + ); + } + // The age exception grants no signer authority and bypasses no envelope or + // signature checks. These calls use the real HTTP -> shared ingest path. + let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + let mut corrupt = raw.clone(); + corrupt["content"] = json!(format!("{}x", aged.content)); + let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + let invalid = EventBuilder::new(aged.kind, &aged.content) + .tag(Tag::identifier("invalid-coordinate")) + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + let future = EventBuilder::new(aged.kind, &aged.content) + .tags(aged.tags.iter().cloned()) + .custom_created_at(Timestamp::from(now + 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let ordinary = EventBuilder::text_note("old ordinary event") + .custom_created_at(aged.created_at) + .sign_with_keys(&owner) + .unwrap(); + for rejected in [invalid, future, ordinary] { + let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{result}"); + } +} + +/// Same-ID transport retry must redeliver to a currently connected Desktop, +/// even when the first attempt arrived while it was absent. It cannot re-date, +/// re-store or expose the request to another owner/community. +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn desktop_stop_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_STOP).await; +} +#[tokio::test] +#[ignore = "requires Postgres and Redis"] +async fn desktop_lifecycle_retry_redelivers_exact_event_only_to_owner() { + assert_retry(KIND_DESKTOP_LIFECYCLE).await; +} +async fn assert_retry(kind: u32) { + use nostr::Filter; + use std::sync::atomic::AtomicU8; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + let mut state = bridge_handler_test_state() + .await + .expect("test infrastructure"); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true; + let host = format!("stop-retry-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let owner = Keys::generate(); + let outsider = Keys::generate(); + let target = buzz_core::desktop_stop::StopTarget { + v: 1, + community: format!("wss://{host}"), + desktop: uuid::Uuid::new_v4().simple().to_string(), + agent: Keys::generate().public_key().to_hex(), + }; + let prepared = if kind == KIND_DESKTOP_STOP { + target.sign(&owner).unwrap() + } else { + buzz_core::desktop_lifecycle::Request { + target, + action: buzz_core::desktop_lifecycle::Action::Start, + observed: None, + configuration: None, + cursor: None, + } + .sign(&owner) + .unwrap() + }; + let event = EventBuilder::new(prepared.kind, &prepared.content) + .tags(prepared.tags.iter().cloned()) + .custom_created_at(Timestamp::from(Timestamp::now().as_secs() - 86_400)) + .sign_with_keys(&owner) + .unwrap(); + let raw = json!(event); + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["accepted"], true); + let mut receivers = vec![]; + for (who, tenant) in [ + (&owner, community), + (&outsider, community), + ( + &owner, + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()), + ), + ] { + let conn = uuid::Uuid::new_v4(); + let (tx, rx) = mpsc::channel(16); + let (ctrl, _) = mpsc::channel(16); + state.conn_manager.register( + conn, + tx, + ctrl, + None, + CancellationToken::new(), + tenant, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(std::collections::HashMap::new())), + 3, + ); + state + .conn_manager + .set_authenticated_pubkey(conn, who.public_key().to_bytes().to_vec()); + state.sub_registry.register_scoped( + tenant, + conn, + "stop".into(), + vec![Filter::new().kind(Kind::Custom(kind as u16))], + None, + ); + receivers.push(rx); + } + // Quiesce the asynchronous first dispatch before measuring the retry. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + for rx in &mut receivers { + drain(rx); + } + for _ in 0..2 { + let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["message"], "duplicate:"); + let frames = drain(&mut receivers[0]); + assert_eq!(frames.len(), 1, "{frames:?}"); + assert_eq!(frames[0][2], raw); + assert!(drain(&mut receivers[1]).is_empty()); + assert!(drain(&mut receivers[2]).is_empty()); + } + let (status, result) = post(&state, &host, "/events", &outsider, raw, true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{result}"); + assert!(drain(&mut receivers[0]).is_empty()); + let (_, rows) = post(&state, &host, "/query", &owner, + json!([{"kinds":[kind],"authors":[owner.public_key().to_hex()], "ids":[event.id.to_hex()]}]), true).await; + assert_eq!(rows.as_array().unwrap().len(), 1); +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 66a8ff9e7c0..c4e4d1f86c9 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -337,6 +337,32 @@ pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pub } } +/// Retry delivery of an already stored Stop, without audit/workflow effects. +/// Admission and durable effect deduplication remain the Desktop's authority. +pub(crate) async fn redeliver_desktop_stop( + tenant: &TenantContext, + state: &Arc, + event: &nostr::Event, +) { + state.mark_local_event(tenant.community(), &event.id); + if let Err(error) = state + .pubsub + .publish_event(tenant, EventTopic::Global, event) + .await + { + state + .local_event_ids + .invalidate(&(tenant.community(), event.id.to_bytes())); + warn!(event_id = %event.id, %error, "Desktop Stop redelivery to peers failed"); + } + fan_out_event_to_local_subscribers( + state, + tenant.community(), + &StoredEvent::new(event.clone(), None), + ) + .await; +} + /// Schedule post-commit delivery/side effects for a stored event. /// /// This intentionally returns after only the bounded audit enqueue has completed: @@ -2207,6 +2233,31 @@ mod tests { #[tokio::test] async fn author_only_reminder_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_EVENT_REMINDER).await; + } + + #[tokio::test] + async fn desktop_profile_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await; + } + + #[tokio::test] + async fn desktop_observation_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await; + } + + #[tokio::test] + async fn desktop_stop_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP).await; + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_STOP_RESULT).await; + } + + #[tokio::test] + async fn desktop_capabilities_delivers_to_author_only() { + assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_CAPABILITIES).await; + } + + async fn assert_author_only_fanout(kind: u32) { let state = test_state().await; let author_keys = Keys::generate(); @@ -2216,12 +2267,9 @@ mod tests { // KIND_EVENT_REMINDER (30300) is in AUTHOR_ONLY_KINDS and is stored // globally (channel_id = None), so the gate must apply independent // of any channel-membership check. - let reminder = EventBuilder::new( - Kind::Custom(buzz_core::kind::KIND_EVENT_REMINDER as u16), - "{}", - ) - .sign_with_keys(&author_keys) - .expect("sign reminder"); + let reminder = EventBuilder::new(Kind::Custom(kind as u16), "{}") + .sign_with_keys(&author_keys) + .expect("sign reminder"); let stored = StoredEvent::new(reminder, None); let author_conn = register_conn(&state, Some(author_pk)); @@ -2233,6 +2281,16 @@ mod tests { (other_conn, "o".to_string()), (unauthed_conn, "u".to_string()), ]; + // Even an authenticated owner subscription in another tenant is denied. + assert!(filter_fanout_by_access( + &state, + buzz_core::CommunityId::from_uuid(Uuid::new_v4()), + &stored, + matches.clone(), + None, + ) + .await + .is_empty()); let out = filter_fanout_by_access( &state, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be9..88d2d43ed4e 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -36,6 +36,10 @@ use buzz_core::kind::{ RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; +use buzz_core::kind::{ + KIND_DESKTOP_CAPABILITIES, KIND_DESKTOP_LIFECYCLE, KIND_DESKTOP_LIFECYCLE_RESULT, + KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE, KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT, +}; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; @@ -436,7 +440,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { - KIND_PROFILE => Ok(Scope::UsersWrite), + KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION | KIND_DESKTOP_CAPABILITIES | KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT @@ -657,6 +661,11 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT + | KIND_DESKTOP_PROFILE + | KIND_DESKTOP_OBSERVATION + | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT | KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -2167,6 +2176,24 @@ pub async fn ingest_event( result } +// Profiles, capabilities and immutable Stop messages are not freshness signals. A Desktop may +// first publish its immutable signed record long after an offline startup. +// Only their past-age bound is waived; future drift and all other admission +// checks still apply. Observation/presence kinds must retain their own window. +fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool { + const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; + event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS) + && (matches!( + kind, + KIND_DESKTOP_PROFILE + | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT + ) || now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS) +} + async fn ingest_event_inner( state: &Arc, tracer: &Arc, @@ -2231,10 +2258,8 @@ async fn ingest_event_inner( } let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone()); - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes let now = chrono::Utc::now().timestamp(); - let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -2781,6 +2806,33 @@ async fn ingest_event_inner( } } + if matches!( + kind_u32, + KIND_DESKTOP_LIFECYCLE | KIND_DESKTOP_LIFECYCLE_RESULT + ) { + buzz_core::desktop_lifecycle::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT) { + buzz_core::desktop_stop::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + + if kind_u32 == KIND_DESKTOP_CAPABILITIES { + buzz_core::desktop_capabilities::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + + if kind_u32 == KIND_DESKTOP_OBSERVATION { + buzz_core::desktop_observation::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + + if kind_u32 == KIND_DESKTOP_PROFILE { + buzz_core::desktop_profile::validate_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_EVENT_REMINDER { validate_event_reminder(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -3204,6 +3256,12 @@ async fn ingest_event_inner( }; if !was_inserted { + // Stop is a one-shot owned by Desktop, not a replaceable projection. + // Explicit transport retry must reach a live receiver even after an ACK + // or its result was lost. Never replay history or repeat relay effects. + if matches!(kind_u32, KIND_DESKTOP_STOP | KIND_DESKTOP_LIFECYCLE) { + super::event::redeliver_desktop_stop(tenant, state, &stored_event.event).await; + } return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -3310,6 +3368,51 @@ mod postgres_tests { )); } + #[test] + fn immutable_profile_age_exception_is_past_only_and_kind_specific() { + let now = 1_800_000_000; + // Include the next observation kind explicitly: freshness is not profile age. + for kind in [ + KIND_DESKTOP_PROFILE, + KIND_DESKTOP_CAPABILITIES, + KIND_DESKTOP_STOP, + KIND_DESKTOP_STOP_RESULT, + 30181, + KIND_PROFILE, + KIND_EVENT_REMINDER, + 1, + ] { + for (timestamp, ordinary, profile) in [ + (0, false, true), + (now - 86_400, false, true), + (now - 901, false, true), + (now - 900, true, true), + (now, true, true), + (now + 900, true, true), + (now + 901, false, false), + (u64::MAX, false, false), + ] { + assert_eq!( + timestamp_within_ingest_window(kind, timestamp, now), + if matches!( + kind, + KIND_DESKTOP_PROFILE + | KIND_DESKTOP_CAPABILITIES + | KIND_DESKTOP_STOP + | KIND_DESKTOP_STOP_RESULT + | KIND_DESKTOP_LIFECYCLE + | KIND_DESKTOP_LIFECYCLE_RESULT + ) { + profile + } else { + ordinary + }, + "kind={kind} timestamp={timestamp}" + ); + } + } + } + #[test] fn huddle_backing_channel_lookup_outage_is_internal() { let error = sqlx::Error::Io(std::io::Error::other("database unavailable")); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index aad2580dad0..3ce7eb3b81f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/desktop-stop.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 093e925f18a..3252f9f7683 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -69,6 +69,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index ccca7c4abfa..22a54a0171c 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -505,7 +505,7 @@ async fn restart_single_agent_after_install( managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_managed_agents, stop_managed_agent_process, + resolve_effective_agent_env, stop_managed_agent_process, storage, sync_managed_agent_processes, AgentReadiness, BackendKind, }, }; @@ -536,7 +536,7 @@ async fn restart_single_agent_after_install( ¤t_instance_id(&app_for_stop), ); if sync_changed { - save_managed_agents(&app_for_stop, &records)?; + storage::save_runtime_metadata_batch(&app_for_stop, &records)?; } // Re-verify eligibility under lock. @@ -590,7 +590,7 @@ async fn restart_single_agent_after_install( // Stop the process. let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; + storage::save_runtime_metadata_batch(&app_for_stop, &records)?; Ok(runtime_keys) }) @@ -644,7 +644,7 @@ fn persist_last_error_on_install( ) -> Result<(), String> { use crate::{ app_state::AppState, - managed_agents::{find_managed_agent_mut, load_managed_agents, save_managed_agents}, + managed_agents::{find_managed_agent_mut, load_managed_agents, storage}, }; use tauri::Manager; let state = app.state::(); @@ -656,7 +656,7 @@ fn persist_last_error_on_install( let record = find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + storage::save_runtime_metadata_batch(app, &records) } /// Build the `-l -c` argument list for the install shell. diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs index 28a50e7b15b..2ccbb7bdc0a 100644 --- a/desktop/src-tauri/src/commands/agent_models_update_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -343,7 +343,8 @@ fn record_field_updates_persist_effort_to_disk() { "last_stopped_at": null, "last_exit_code": null, "last_error": null })) .unwrap(); - save_managed_agents(app.handle(), &[seed]).unwrap(); + crate::managed_agents::storage::save_managed_agents_with_new_keys(app.handle(), &[seed]) + .unwrap(); // Drive the production seam: load → apply_record_field_updates → // stamp_record_updated_at → save. This is the exact sequence that diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 1371abba2c6..25ea58db0d3 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -97,6 +97,18 @@ pub async fn set_managed_agent_auto_restart( { let record = find_managed_agent_mut(&mut records, &pubkey)?; + if auto_restart_on_config_change + && !record + .runtime_configurations + .get( + &state.signing_keys()?.public_key().to_hex(), + &crate::relay::relay_ws_url_with_override(&state), + ) + .entries + .is_empty() + { + return Err("Named runtime configurations apply on deliberate next Start".into()); + } record.auto_restart_on_config_change = auto_restart_on_config_change; record.updated_at = now_iso(); } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c5..7596feb5ad6 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,5 +1,5 @@ use nostr::{Keys, ToBech32}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use super::managed_agent_definition::validate_create_definition; @@ -10,11 +10,11 @@ use crate::{ build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agents_base_dir, normalize_agent_args, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + save_managed_agents, stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -37,8 +37,8 @@ pub(crate) use pending::{retain_managed_agent_pending, tombstone_managed_agent_p /// For one-shot command paths only — the 5s list poll calls /// `build_managed_agent_summary` directly with stores loaded once per call, /// not once per record. -pub(super) fn summarize_from_disk( - app: &AppHandle, +pub(super) fn summarize_from_disk( + app: &AppHandle, record: &ManagedAgentRecord, runtimes: &std::collections::HashMap< crate::managed_agents::ManagedAgentRuntimeKey, @@ -83,58 +83,95 @@ pub(super) async fn start_local_agent_pairs_with_preflight( pubkey: &str, relay_urls: &[String], ) -> Result { - let record_snapshot = { - let _store_guard = state + start_local_agent_pairs_with_preflight_using(app, state, pubkey, relay_urls, + |model, allow| async move { ensure_relay_mesh_for_record(app, model.as_deref(), allow).await }).await +} + +pub(crate) async fn start_local_agent_pairs_with_preflight_using( + app: &AppHandle, + state: &AppState, + pubkey: &str, + relay_urls: &[String], + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + use crate::managed_agents::runtime_configurations as configurations; + let owner = workspace_owner_hex(state)?; + // Snapshot all pairs before the first await. Each community has a private + // selection; a single Default preflight cannot authorize this restart batch. + let plans = { + let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - load_managed_agents(app)? - .into_iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))? + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + refresh_launch_persona(app, record)?; + let plans = relay_urls + .iter() + .map(|relay| { + let key = crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey, relay)?; + configurations::capture_for_app(app, record, &owner, &key.relay_url) + .map(|plan| (key.relay_url, plan)) + }) + .collect::, String>>()?; + save_managed_agents(app, &records)?; + retain_managed_agent_pending(app, state, find_managed_agent_mut(&mut records, pubkey)?); + plans }; - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); + let mut errors = Vec::new(); + let mut ready = Vec::new(); + for (relay, mut plan) in plans { + match configurations::preflight_with(&mut plan, &owner, &relay, false, &preflight).await { + Ok(()) => ready.push((relay, plan)), + Err(error) => errors.push(format!("{relay}: {error}")), + } } - let personas_for_preflight = load_personas(app).unwrap_or_default(); - let global_for_preflight = - crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas_for_preflight, - &global_for_preflight, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; - + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + crate::relay::bind_expected_signer(Some(&owner), workspace_owner_hex(state)?)?; + // Check the batch's Stop marker once before any pair writes record-level + // lifecycle bookkeeping. Each successful pair clears last_stopped_at, which + // must not invalidate the remaining communities in this same locked batch. { - let _store_guard = state + let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - } - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("Agent removed during preflight")?; + for (_, plan) in &ready { + plan.check_continuation(record)?; } } - - let mut errors = Vec::new(); - for relay_url in relay_urls { - if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy( - pubkey.to_string(), - relay_url.clone(), + for (relay, plan) in ready { + // Selection and prerequisites are checked under the same store lock as + // termination/spawn, with the exact captured plan (including Default). + if let Err(error) = crate::managed_agents::start_pair_captured_locked( + pubkey.into(), + relay.clone(), + true, + None, + None, + &plan, + true, + false, + None, app.clone(), ) { - errors.push(format!("{relay_url}: {error}")); + errors.push(format!("{relay}: {error}")); } } if !errors.is_empty() { @@ -143,8 +180,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight( errors.join("; ") )); } - - let _store_guard = state + let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; @@ -160,48 +196,126 @@ pub(super) async fn start_local_agent_pairs_with_preflight( summarize_from_disk(app, record, &runtimes) } -pub(super) async fn start_local_agent_with_preflight( +fn refresh_launch_persona( + app: &AppHandle, + record: &mut ManagedAgentRecord, +) -> Result<(), String> { + if let Some(id) = record.persona_id.clone() { + let personas = load_personas(app)?; + let persona = personas + .iter() + .find(|p| p.id == id) + .ok_or(crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR)?; + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + Ok(()) +} + +pub(crate) enum LocalStartIntent { + Create, + Explicit, + Automatic, +} + +// The argument tuple is the launch-inputs ABI this seam forwards verbatim to +// `start_local_agent_with_preflight_using` (which carries the same allow): the +// capture → preflight → locked-revalidate → spawn inputs. Collapsing it into +// a params struct would fork the shared `_using` boundary shape for a lint. +#[allow(clippy::too_many_arguments)] +async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, - allow_fresh_create_start: bool, + intent: LocalStartIntent, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, + requested: Option< + Option<&crate::managed_agents::runtime_configurations::RuntimeConfigurationRef>, + >, ) -> Result { - let record_snapshot = { - let _store_guard = state + start_local_agent_with_preflight_using(app, state, pubkey, intent, + expected_relay_url, expected_signer_pubkey, replay_floor_unix, requested, + |model, allow| async move { ensure_relay_mesh_for_record(app, model.as_deref(), allow).await }).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn start_local_agent_with_preflight_using( + app: &AppHandle, + state: &AppState, + pubkey: &str, + intent: LocalStartIntent, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, + requested: Option< + Option<&crate::managed_agents::runtime_configurations::RuntimeConfigurationRef>, + >, + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + let launch_owner = workspace_owner_hex(state)?; + // Runtime keys preserve the workspace host authority. Bind that same + // authority across the preflight await so the eventual spawn cannot move. + let launch_relay = crate::relay::bind_expected_relay_scope( + expected_relay_url, + relay_ws_url_with_override(state), + )?; + let launch_key = + crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey, launch_relay.as_str())?; + let resume = if matches!(intent, LocalStartIntent::Explicit) { + Some(crate::managed_agents::remote_stop::capture_resume( + app, + &launch_key, + launch_relay.as_str(), + &launch_owner, + )?) + } else { + None + }; + let mut prepared = { + let _store = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - records - .iter() - .find(|record| record.pubkey == pubkey) - .cloned() - .ok_or_else(|| format!("agent {pubkey} not found"))? + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + refresh_launch_persona(app, record)?; + let plan = match requested { + Some(reference) => crate::managed_agents::runtime_configurations::prepare_for_app( + app, + record, + reference, + &launch_owner, + launch_relay.as_str(), + )?, + None => crate::managed_agents::runtime_configurations::capture_for_app( + app, + record, + &launch_owner, + launch_relay.as_str(), + )?, + }; + save_managed_agents(app, &records)?; + plan }; - - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); - } - - // Preflight against the same resolution spawn uses — `resolve_effective_config` - // (definition → global fallback). A linked instance's own `provider`/`model`/ - // `relay_mesh` bytes never contribute: this reads the CURRENT definition - // directly, so a definition edit that flips `provider` to/from relay-mesh - // between saves is reflected here without needing a prospective re-snapshot; - // for a global-inherited blank definition, it also folds in the global - // default, which record-byte sniffing could never see. - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas, - &global, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + let configuration = prepared.configuration(); + crate::managed_agents::runtime_configurations::preflight_with( + &mut prepared, + &launch_owner, + launch_relay.as_str(), + matches!(intent, LocalStartIntent::Create), + preflight, + ) + .await?; // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -211,16 +325,20 @@ pub(super) async fn start_local_agent_with_preflight( // below — the check is tied to its use, so a switch landing after this // point can no longer retarget the spawn (it only changes state this // call no longer consults). - let workspace_relay_url = crate::relay::bind_expected_relay_scope( - expected_relay_url, - crate::relay::relay_ws_url_with_override(state), - )?; + let workspace_relay_url = + launch_relay.revalidate(crate::relay::relay_ws_url_with_override(state))?; // Bind the active owner after the same final await as the relay. A // same-relay identity replacement during mesh preflight must not release // the stale preflight owner to spawn. - let workspace_owner = - crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; + let workspace_owner = crate::relay::bind_expected_signer( + expected_signer_pubkey.or(Some(launch_owner.as_str())), + workspace_owner_hex(state)?, + )?; + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -234,36 +352,32 @@ pub(super) async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - match personas.iter().find(|p| p.id == persona_id) { - Some(persona) => { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - None => { - return Err( - crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), - ); - } - } + if requested.is_none() { + crate::managed_agents::runtime_configurations::check_selection( + record, + Some(&launch_owner), + workspace_relay_url.as_str(), + configuration.as_ref(), + )?; } - start_managed_agent_process( + prepared.check_continuation(record)?; + prepared.revalidate( + record, + &load_personas(app)?, + &crate::managed_agents::load_global_agent_config(app)?, + )?; + let personas = load_personas(app)?; + crate::managed_agents::start_managed_agent_process_prepared( app, record, &mut runtimes, Some(workspace_owner.as_str()), &workspace_relay_url, replay_floor_unix, + resume.as_ref(), + Some(&prepared), )?; - save_managed_agents(app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { retain_managed_agent_pending(app, state, saved_record); } @@ -307,7 +421,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result agent, Err(error) => { @@ -729,7 +854,7 @@ pub async fn create_managed_agent( let record = find_managed_agent_mut(&mut records, &pubkey)?; record.updated_at = now_iso(); record.last_error = Some(error.clone()); - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?; spawn_error = Some(error); let record = records .iter() @@ -824,6 +949,7 @@ pub async fn start_managed_agent( expected_relay_url: Option, expected_signer_pubkey: Option, replay_floor_unix: Option, + explicit_start: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -880,7 +1006,7 @@ pub async fn start_managed_agent( let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); if sync_changed { - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?; } for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); @@ -920,10 +1046,15 @@ pub async fn start_managed_agent( &app, &state, &pubkey, - false, + if explicit_start.unwrap_or(false) { + LocalStartIntent::Explicit + } else { + LocalStartIntent::Automatic + }, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), replay_floor_unix, + None, ) .await } @@ -1024,7 +1155,7 @@ pub async fn stop_managed_agent( let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); if sync_changed { - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?; } for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); @@ -1043,7 +1174,7 @@ pub async fn stop_managed_agent( // the config-restart flows still drain every pair. stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; } - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?; let record = records .iter() .find(|record| record.pubkey == pubkey) @@ -1102,7 +1233,7 @@ pub async fn delete_managed_agent( ¤t_instance_id(&app), ); if sync_changed { - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?; } for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); @@ -1174,3 +1305,30 @@ use profile::{profile_needs_sync, resolve_legacy_avatar}; #[cfg(test)] #[path = "agents_tests.rs"] mod tests; + +/// Start the explicitly reviewed revision through ordinary local async preflight. +#[tauri::command] +pub async fn start_runtime_configuration( + app: AppHandle, + owner: String, + community: String, + agent: String, + configuration: Option, +) -> Result { + let state = app.state::(); + super::desktop_profiles::scope(&app, &state, &owner, &community)?; + if !super::desktop_stop::owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + start_local_agent_with_preflight( + &app, + &state, + &agent, + LocalStartIntent::Explicit, + Some(&community), + Some(&owner), + None, + Some(configuration.as_ref()), + ) + .await +} diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs index 0a7f91eb854..8c8683a3c4c 100644 --- a/desktop/src-tauri/src/commands/agents_pending.rs +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -22,8 +22,8 @@ use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; /// only runtime fields produces an identical row and never re-enqueues a /// publish. Best-effort: a failure here is logged and swallowed so a retention /// hiccup never blocks the disk-authoritative write. -pub(crate) fn retain_managed_agent_pending( - app: &AppHandle, +pub(crate) fn retain_managed_agent_pending( + app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) { diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 59e04b09ff0..e58e9c8adf2 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -9,6 +9,7 @@ fn bare_agent_record( use crate::managed_agents::{BackendKind, RespondTo}; use std::collections::BTreeMap; ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/desktop_capabilities.rs b/desktop/src-tauri/src/commands/desktop_capabilities.rs new file mode 100644 index 00000000000..f5efd5c953b --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_capabilities.rs @@ -0,0 +1,224 @@ +//! Private Desktop reports reuse the local catalog authority and retention scope. +use super::desktop_profiles::{prepare, scope}; +use crate::{ + app_state::AppState, + managed_agents::{ + retention::{open_retention_db, RetentionScope}, + AcpRuntimeCatalogEntry, HarnessSource, + }, +}; +use buzz_core_pkg::{ + desktop_capabilities::{DesktopCapabilities, RuntimeFact}, + desktop_profile::DesktopProfile, +}; +use nostr::{Event, JsonUtil}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +fn project(catalog: Vec) -> Result, String> { + catalog + .into_iter() + .filter(|r| r.source == HarnessSource::Builtin) + .map(|r| { + Ok(RuntimeFact { + id: r.id, + availability: serde_json::from_value( + serde_json::to_value(r.availability).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?, + requires_external_cli: r.requires_external_cli, + max_parallelism: r.max_parallelism, + }) + }) + .collect() +} + +/// Cached discovery only; Settings → Agents remains the local setup/check-again UI. +#[tauri::command] +pub async fn prepare_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + // Serialize discovery + persistence so an older native completion cannot + // overwrite a newer projection when observers cancel/restart. + static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _guard = SERIAL.lock().await; + scope(&app, &state, &owner, &community)?; + let facts = + project(super::agent_discovery::discover_acp_providers(app.clone(), Some(false)).await?)?; + let scope = scope(&app, &state, &owner, &community)?; + Ok( + json!({ "event": prepare_report(&mut open_retention_db(&scope.db_path)?, &scope, facts, nostr::Timestamp::now)? }), + ) +} + +fn prepare_report( + conn: &mut Connection, + scope: &RetentionScope, + facts: Vec, + clock: impl FnOnce() -> nostr::Timestamp, +) -> Result { + let saved = prepare(conn, scope)?; + let profile: Event = + serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let community = scope.relay_url.trim_end_matches('/'); + let report = DesktopCapabilities::new( + DesktopProfile::read(&profile, &scope.owner_keys, community)?, + facts, + ); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch("CREATE TABLE IF NOT EXISTS desktop_capabilities (slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);").map_err(|e| e.to_string())?; + let raw: Option = tx + .query_row( + "SELECT raw FROM desktop_capabilities WHERE slot = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let previous = raw + .map(|raw| Event::from_json(raw).map_err(|e| e.to_string())) + .transpose()?; + let unchanged = previous + .as_ref() + .map(|e| { + DesktopCapabilities::read(e, &scope.owner_keys, community).map(|old| old == report) + }) + .transpose()? + .unwrap_or(false); + let event = match previous { + Some(event) if unchanged => event, + previous => { + let now = clock(); + // Keep the prior retry record until real time advances. Signing tied + // ciphertext can lose NIP-33's lower-ID tie; never cache that loss or + // future-date a replacement. The existing pulse/reconnect/Refresh + // retries discovery, not a captured projection, without waiting here. + if previous.as_ref().is_some_and(|e| now <= e.created_at) { + return Err("Desktop capability facts deferred until the clock advances".into()); + } + let event = report.sign_at(&scope.owner_keys, now)?; + tx.execute( + "INSERT OR REPLACE INTO desktop_capabilities VALUES (1, ?1)", + [event.as_json()], + ) + .map_err(|e| e.to_string())?; + event + } + }; + tx.commit().map_err(|e| e.to_string())?; + Ok(event) +} + +/// Read only verified owner/community reports, newest signed time then lower ID. +#[tauri::command] +pub fn read_desktop_capabilities( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + let rows: Vec<_> = DesktopCapabilities::read_latest(events, &scope.owner_keys, &community)?.into_iter() + .map(|(report, reported)| json!({ "id": report.id, "reported": reported, "runtimes": report.runtimes })).collect(); + Ok(json!(rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn changed_facts_defer_until_real_clock_advances_then_win_signed_order() { + let dir = tempfile::tempdir().unwrap(); + let scope = RetentionScope { + db_path: dir.path().join("report.db"), + relay_url: "wss://one.example".into(), + owner_keys: nostr::Keys::generate(), + }; + let first = prepare_report( + &mut open_retention_db(&scope.db_path).unwrap(), + &scope, + vec![], + || nostr::Timestamp::from(1000), + ) + .unwrap(); + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + assert_eq!( + prepare_report(&mut reopened, &scope, vec![], || panic!( + "unchanged must not sign" + )) + .unwrap(), + first + ); + assert_eq!(reopened.total_changes(), 0); + let mut facts = vec![RuntimeFact { + id: "goose".into(), + availability: "available".into(), + requires_external_cli: true, + max_parallelism: None, + }]; + for now in [1000, 990, 999, 1000] { + let error = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(now) + }) + .unwrap_err(); + assert!(error.contains("clock advances")); + assert_eq!(reopened.total_changes(), 0, "deferral must not persist"); + // Returning to old facts cancels the proposed change, even after a + // restart/rollback: no deferred payload or timestamp renewal survives. + assert_eq!( + prepare_report(&mut reopened, &scope, vec![], || panic!("exact retry")).unwrap(), + first + ); + reopened = open_retention_db(&scope.db_path).unwrap(); + } + // The retry observes today's facts, not the projection first deferred. + facts[0].availability = "cli_missing".into(); + let changed = prepare_report(&mut reopened, &scope, facts.clone(), || { + nostr::Timestamp::from(1001) + }) + .unwrap(); + first.verify().unwrap(); + changed.verify().unwrap(); + assert_eq!(changed.created_at.as_secs(), 1001, "no future timestamp"); + assert!(changed.created_at > first.created_at); + assert_eq!(changed.tags, first.tags); + for events in [ + vec![first.clone(), changed.clone()], + vec![changed.clone(), first], + ] { + let rows = + DesktopCapabilities::read_latest(events, &scope.owner_keys, &scope.relay_url) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].0.runtimes, facts); + assert_eq!(rows[0].1, 1001); + } + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + let mut invalid = facts.clone(); + invalid[0].max_parallelism = Some(0); + assert!(prepare_report(&mut reopened, &scope, invalid, || { + nostr::Timestamp::from(1002) + }) + .is_err()); + assert_eq!( + prepare_report(&mut reopened, &scope, facts, || panic!("exact retry")).unwrap(), + changed + ); + assert_eq!( + reopened.total_changes(), + 0, + "failed signing must not persist" + ); + reopened + .execute("UPDATE desktop_capabilities SET raw = 'corrupt'", []) + .unwrap(); + assert!(prepare_report(&mut reopened, &scope, vec![], nostr::Timestamp::now).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle.rs b/desktop/src-tauri/src/commands/desktop_lifecycle.rs new file mode 100644 index 00000000000..9fe4132a546 --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_lifecycle.rs @@ -0,0 +1,465 @@ +//! Trusted Desktop lifecycle adapter. Historical projection never launches. +use super::{ + desktop_profiles::scope, + desktop_stop::{local_id, owned_local}, +}; +use crate::{ + app_state::AppState, + managed_agents::{self, placement, retention::open_retention_db}, +}; +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Outcome, Request, ResultMessage, RuntimeConfigurationRef}, + desktop_stop::StopTarget, +}; +use nostr::{Event, JsonUtil}; +use rusqlite::{params, OptionalExtension}; +use tauri::{AppHandle, Manager}; + +mod configurations; + +// Tauri IPC command boundary: the nine parameters are the existing frontend +// invoke contract (each named argument is passed individually from +// `desktopLifecycle.ts`), so the shape is ABI-fixed here — a params struct +// would change the wire format. Same command-boundary allow as the other +// wide `#[tauri::command]` (`send_managed_agent_channel_message`). +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub fn prepare_desktop_lifecycle( + app: AppHandle, + owner: String, + community: String, + desktop: String, + agent: String, + action: Action, + observed: Option, + configuration: Option, + cursor: Option, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + let event = Request { + target: StopTarget { + v: 1, + community, + desktop, + agent, + }, + action, + observed, + configuration, + cursor, + } + .sign(&scope.owner_keys)?; + let conn = open_retention_db(&scope.db_path)?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_lifecycle_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1),raw TEXT NOT NULL)").map_err(|e|e.to_string())?; + conn.execute("INSERT INTO desktop_lifecycle_outgoing VALUES(1,?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw",[event.as_json()]).map_err(|e|e.to_string())?; + Ok(event) +} + +/// Authenticated projection batches only; no Stop/Restart command replay. Stops +/// caused by superseded placement reuse the ordinary local lifecycle owner. +#[tauri::command] +pub async fn observe_desktop_placement( + app: AppHandle, + owner: String, + community: String, + events: Vec, + reconcile: bool, +) -> Result<(), String> { + if events.len() > 256 { + return Err("Too many placement events".into()); + } + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + let mut agents = std::collections::BTreeSet::new(); + let mut projection = events.is_empty(); + for event in events { + if event.kind.as_u16() as u32 == buzz_core_pkg::kind::KIND_DESKTOP_LIFECYCLE + && matches!( + Request::read(&event, &scope.owner_keys, &community)?.action, + Action::Catalog | Action::Preflight + ) + { + continue; + } + projection = true; + agents.insert(placement::observe( + &conn, + &event, + &scope.owner_keys, + &community, + )?); + } + if !reconcile || !projection { + return Ok(()); + } + placement::schema(&conn)?; + let mut query = conn + .prepare("SELECT DISTINCT agent FROM desktop_placement") + .map_err(|e| e.to_string())?; + for row in query + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())? + { + agents.insert(row.map_err(|e| e.to_string())?); + } + for agent in agents { + if placement::blocked(&conn, &agent, &desktop)? + && owned_local(&app, &state, &owner, &agent)? + { + // Merely learning old Stop while no child exists performs no effect. + if generation(&app, &state, &agent, &community).map_or(true, |g| g.is_some()) { + managed_agents::stop_pair_locked(agent, community.clone(), app.clone())?; + } + } + } + Ok(()) + }) + .await + .map_err(|e| format!("Placement task failed: {e}"))? +} + +#[tauri::command] +pub fn read_desktop_placement( + app: AppHandle, + owner: String, + community: String, + agent: String, +) -> Result, String> { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + placement::latest_start(&open_retention_db(&scope.db_path)?, &agent) +} + +fn generation( + app: &AppHandle, + state: &AppState, + agent: &str, + community: &str, +) -> Result, String> { + let key = managed_agents::ManagedAgentRuntimeKey::new(agent, community)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + if let Some(runtime) = runtimes.get_mut(&key) { + if runtime + .child + .try_wait() + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(Some(runtime.start_nonce.clone())); + } + } + drop(runtimes); + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::storage::load_agent_store(app)?; + let legacy = records + .iter() + .find(|r| r.pubkey == agent) + .and_then(|r| r.runtime_pid); + let dir = managed_agents::managed_agents_base_dir(app)?.join("agent-pids"); + // A receipt may represent a surviving untracked child. Never turn a missing + // in-memory handle, unreadable receipt, or legacy live PID into Stopped. + if legacy.is_some_and(managed_agents::process_is_running) + || dir + .join(format!("{}.json", key.runtime_id())) + .try_exists() + .map_err(|e| e.to_string())? + || dir + .join(format!("{agent}.pid")) + .try_exists() + .map_err(|e| e.to_string())? + { + return Err("Local process state is untracked; use ordinary Desktop Stop".into()); + } + Ok(None) +} + +fn status( + app: &AppHandle, + conn: &rusqlite::Connection, + state: &AppState, + event: &Event, + request: &Request, +) -> Result { + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?; + let Some(nonce) = generation(app, state, &request.target.agent, &request.target.community)? + else { + return Ok(Outcome::Stopped); + }; + conn.execute( + "INSERT OR REPLACE INTO desktop_status_generation VALUES(?1,?2,?3)", + params![event.id.to_hex(), nonce, nostr::Timestamp::now().as_secs()], + ) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM desktop_status_generation WHERE rowid NOT IN (SELECT rowid FROM desktop_status_generation ORDER BY rowid DESC LIMIT 256)",[]).map_err(|e|e.to_string())?; + Ok(Outcome::Running) +} +fn current_observation( + app: &AppHandle, + conn: &rusqlite::Connection, + state: &AppState, + request: &Request, +) -> Result { + let Some(id) = request.observed.as_deref() else { + return Ok(false); + }; + // A request cannot create this local record; only a real Status observation can. + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_status_generation (id TEXT PRIMARY KEY,nonce TEXT NOT NULL,observed INTEGER NOT NULL)").map_err(|e|e.to_string())?; + let saved: Option<(String, u64)> = conn + .query_row( + "SELECT nonce,observed FROM desktop_status_generation WHERE id=?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(match saved { + Some((nonce, stamp)) if nostr::Timestamp::now().as_secs().saturating_sub(stamp) <= 30 => { + generation(app, state, &request.target.agent, &request.target.community)?.as_deref() + == Some(nonce.as_str()) + } + _ => false, + }) +} + +#[tauri::command] +pub async fn receive_desktop_lifecycle( + app: AppHandle, + owner: String, + community: String, + event: Event, +) -> Result, String> { + let preflight_app = app.clone(); + receive_desktop_lifecycle_with(app, owner, community, event, move |model, allow| { + let app = preflight_app.clone(); + async move { + #[cfg(feature = "mesh-llm")] + { + crate::commands::ensure_relay_mesh_for_record(&app, model.as_deref(), allow).await + } + #[cfg(not(feature = "mesh-llm"))] + { + let _ = (app, model, allow); + Ok(()) + } + } + }) + .await +} + +// Production receiver with only ordinary provider I/O replaceable in isolated tests. +pub(crate) async fn receive_desktop_lifecycle_with( + app: AppHandle, + owner: String, + community: String, + event: Event, + preflight: F, +) -> Result, String> +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + // Authenticate and fence the destination before reading any configuration or + // doing ordinary async preflight. Never hold the transition lock across await. + let request = { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + let request = Request::read(&event, &scope.owner_keys, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + if local_id(&mut conn, &scope)? != request.target.desktop { + return Ok(None); + } + if let Some(saved) = placement::saved(&conn, &event.id.to_hex())? { + ResultMessage::read(&saved, &scope.owner_keys, &event, &community)?; + return Ok(Some(saved)); + } + request + }; + let owned = owned_local( + &app, + &app.state::(), + &owner, + &request.target.agent, + )?; + let fresh = nostr::Timestamp::now().as_secs() < event.created_at.as_secs().saturating_add(30); + let mut prepared = if owned + && fresh + && matches!( + request.action, + Action::Start | Action::Restart | Action::Preflight + ) { + configurations::prepare(&app, &owner, &request, &preflight).await + } else { + Err(Outcome::Ineligible) + }; + if let Ok(captured) = &mut prepared { + captured + .plan + .expire_at(event.created_at.as_secs().saturating_add(30)); + } + let catalog = if owned && fresh && request.action == Action::Catalog { + configurations::catalog(&app, &owner, &request, &preflight).await + } else { + Err("Catalog is unavailable".into()) + }; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let request = Request::read(&event, &scope.owner_keys, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + let owned = owned_local(&app, &state, &owner, &request.target.agent)?; + placement::receive( + &mut conn, + &event, + &scope.owner_keys, + &community, + &desktop, + owned, + |conn, request| { + let (outcome, page) = match request.action { + Action::Status => (status(&app, conn, &state, &event, request)?, None), + Action::Catalog => match catalog { + Ok(page) => (Outcome::Ready, Some(page)), + Err(_) => (Outcome::Ineligible, None), + }, + Action::Preflight => ( + match prepared.as_ref() { + Ok(plan) + if configurations::revalidate(&app, &owner, request, plan) + .is_ok() => + { + Outcome::Ready + } + _ => Outcome::Ineligible, + }, + None, + ), + _ => ( + execute(&app, &state, conn, &owner, request, prepared.as_ref())?, + None, + ), + }; + let running = + if matches!(outcome, Outcome::Running | Outcome::DifferentConfiguration) { + configurations::running(&state, request)? + } else { + None + }; + Ok(( + outcome, + Some(configurations::observation(&event, running, page)), + )) + }, + ) + }) + .await + .map_err(|e| format!("Desktop lifecycle task failed: {e}"))? +} + +fn execute( + app: &AppHandle, + state: &AppState, + conn: &rusqlite::Connection, + owner: &str, + request: &Request, + prepared: Result<&configurations::CapturedLaunch, &Outcome>, +) -> Result { + let target = &request.target; + if request.configuration.is_none() { + // Old requests remain readable for placement history, never implicit launches. + return Ok(Outcome::Ineligible); + } + if request.action == Action::Restart && !current_observation(app, conn, state, request)? { + return Ok(Outcome::Unknown); + } + if request.action == Action::Start + && generation(app, state, &target.agent, &target.community)?.is_some() + { + return Ok( + if configurations::running(state, request)? == request.configuration { + Outcome::Running + } else { + Outcome::DifferentConfiguration + }, + ); + } + let plan = match prepared { + Ok(plan) => plan, + Err(outcome) => return Ok(*outcome), + }; + if configurations::revalidate(app, owner, request, plan).is_err() { + return Ok(Outcome::Ineligible); + } + // Shared admission validates the captured Stop fence and generation BEFORE + // destructive Restart. The transition lock spans that Stop, launch and receipt. + if placement::blocked(conn, &target.agent, &target.desktop)? { + return Ok(Outcome::Unknown); + } + match managed_agents::start_pair_captured_locked( + target.agent.clone(), + target.community.clone(), + true, + None, + plan.resume.as_ref(), + &plan.plan, + false, // Explicit wire reference, not the destination's next selection. + request.action == Action::Restart, + Some(&plan.generation), + app.clone(), + ) { + Ok(status) + if status.running_configuration == request.configuration + && !matches!( + status.lifecycle, + managed_agents::ManagedAgentRuntimeLifecycle::Failed + | managed_agents::ManagedAgentRuntimeLifecycle::Stopped + ) => + { + Ok(Outcome::Running) + } + Ok(_) | Err(_) => Ok(Outcome::Failed), + } +} + +#[tauri::command] +pub fn read_desktop_lifecycle_results( + app: AppHandle, + owner: String, + community: String, + request: Event, + events: Vec, +) -> Result, String> { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + Request::read(&request, &scope.owner_keys, &community)?; + if events.len() > 16 { + return Err("Too many lifecycle results".into()); + } + let mut outcome = None; + for event in events { + let result = ResultMessage::read(&event, &scope.owner_keys, &request, &community)?; + if result.outcome != Outcome::Unknown { + outcome = Some(result); + } + } + Ok(outcome) +} diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs new file mode 100644 index 00000000000..89716e31ae4 --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs @@ -0,0 +1,221 @@ +//! Adapter only: configuration storage, async preflight and spawn belong to the shared launcher. +use super::*; +use buzz_core_pkg::desktop_lifecycle::{CatalogPage, Observation, RuntimeConfigurationSummary}; +use managed_agents::runtime_configurations::{self as configurations, PreparedLaunch}; + +/// The shared launch plan and its pre-await lifecycle fences; never wire data. +pub(super) struct CapturedLaunch { + pub(super) plan: PreparedLaunch, + pub(super) resume: Option, + pub(super) generation: Option, +} + +fn record( + app: &AppHandle, + agent: &str, +) -> Result { + let state = app.state::(); + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + managed_agents::storage::load_managed_agents_for_launch(app)? + .into_iter() + .find(|r| r.pubkey == agent) + .ok_or_else(|| "Agent is not provisioned on this Desktop".into()) +} + +pub(super) async fn prepare( + app: &AppHandle, + owner: &str, + request: &Request, + preflight: &F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + let reference = request.configuration.as_ref().ok_or(Outcome::Ineligible)?; + let mut captured = (|| -> Result { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + scope(app, &state, owner, &request.target.community)?; + let record = record(app, &request.target.agent)?; + let plan = configurations::prepare_for_app( + app, + &record, + Some(reference), + owner, + &request.target.community, + )?; + let key = managed_agents::ManagedAgentRuntimeKey::new( + &request.target.agent, + &request.target.community, + )?; + // Only explicit Start can supersede an older Stop. Capture its existing + // shared fence before await; probes and Restart receive no resume authority. + let resume = if request.action == Action::Start { + Some(managed_agents::remote_stop::capture_resume( + app, + &key, + &request.target.community, + owner, + )?) + } else { + None + }; + let generation = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())? + .get(&key) + .map(|runtime| runtime.start_nonce.clone()); + Ok(CapturedLaunch { + plan, + resume, + generation, + }) + })() + .map_err(|_| Outcome::Ineligible)?; + configurations::preflight_with( + &mut captured.plan, + owner, + &request.target.community, + false, + preflight, + ) + .await + .map_err(|_| Outcome::Ineligible)?; + Ok(captured) +} + +pub(super) fn revalidate( + app: &AppHandle, + owner: &str, + request: &Request, + captured: &CapturedLaunch, +) -> Result<(), String> { + let plan = &captured.plan; + plan.require_preflight()?; + plan.check_scope(Some(owner), &request.target.community)?; + if plan.configuration().as_ref() != request.configuration.as_ref() { + return Err("Prepared configuration does not match the request".into()); + } + plan.revalidate( + &record(app, &request.target.agent)?, + &managed_agents::load_personas(app)?, + &managed_agents::load_global_agent_config(app)?, + ) +} + +pub(super) async fn catalog( + app: &AppHandle, + owner: &str, + request: &Request, + preflight: &F, +) -> Result +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + let record = record(app, &request.target.agent)?; + let mut entries = + configurations::catalog_for_app(app, &record, owner, &request.target.community)? + .into_iter() + .filter_map(|entry| { + Some(RuntimeConfigurationSummary { + configuration: entry.configuration?, + name: entry.name, + host: entry.host, + runtime: entry.runtime, + model: entry.model?, + provider: entry.provider, + eligible: entry.eligible, + }) + }) + .filter(|entry| entry.host == request.target.desktop) + .collect::>(); + entries.sort_by(|a, b| a.configuration.id.cmp(&b.configuration.id)); + if entries.len() > 32 + || entries + .windows(2) + .any(|w| w[0].configuration.id == w[1].configuration.id) + { + return Err("Configuration catalog is invalid".into()); + } + // An unknown/deleted cursor is not an authoritative empty tail. + let index = match &request.cursor { + Some(cursor) => { + entries + .iter() + .position(|e| &e.configuration.id == cursor) + .ok_or("Configuration catalog changed; refresh")? + + 1 + } + None => 0, + }; + let next = entries + .get(index + 1) + .and_then(|_| entries.get(index)) + .map(|e| e.configuration.id.clone()); + let mut entry = entries.into_iter().nth(index); + if let Some(entry) = &mut entry { + entry.validate(&request.target.desktop)?; + if entry.eligible { + let probe = Request { + action: Action::Preflight, + configuration: Some(entry.configuration.clone()), + cursor: None, + ..request.clone() + }; + entry.eligible = match prepare(app, owner, &probe, preflight).await { + Ok(captured) => { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + scope(app, &state, owner, &request.target.community)?; + revalidate(app, owner, &probe, &captured).is_ok() + } + Err(_) => false, + }; + } + } + Ok(CatalogPage { entry, next }) +} + +pub(super) fn observation( + event: &Event, + running_configuration: Option, + catalog: Option, +) -> Observation { + Observation { + valid_until: event.created_at.as_secs().saturating_add(30), + running_configuration, + catalog, + } +} + +/// Read only the live process snapshot, never the saved next-launch selection. +pub(super) fn running( + state: &AppState, + request: &Request, +) -> Result, String> { + let key = managed_agents::ManagedAgentRuntimeKey::new( + &request.target.agent, + &request.target.community, + )?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + Ok(runtimes + .get(&key) + .and_then(|runtime| runtime.spawn_config.runtime_configuration.clone())) +} diff --git a/desktop/src-tauri/src/commands/desktop_profiles.rs b/desktop/src-tauri/src/commands/desktop_profiles.rs new file mode 100644 index 00000000000..a8c19e324f2 --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_profiles.rs @@ -0,0 +1,209 @@ +//! Durable read-only Desktop profiles, separate from persona publication queues. +use buzz_core_pkg::{desktop_observation::DesktopObservation, desktop_profile::DesktopProfile}; +use nostr::{Event, JsonUtil}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior}; +use serde_json::{json, Value}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; +use crate::managed_agents::retention::{active_retention_scope, open_retention_db, RetentionScope}; + +pub(super) fn scope( + app: &AppHandle, + state: &AppState, + owner: &str, + community: &str, +) -> Result { + let scope = active_retention_scope(app, state)?; + if scope.owner_keys.public_key().to_hex() != owner + || scope.relay_url.trim_end_matches('/') != community + { + return Err("Desktop profile scope changed".into()); + } + Ok(scope) +} + +pub(super) fn prepare(conn: &mut Connection, scope: &RetentionScope) -> Result { + // SQLite serializes concurrent startup/open requests across processes. The ID + // and exact ciphertext/signature commit together, before any network write. + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS desktop_profile ( + slot INTEGER PRIMARY KEY CHECK(slot = 1), raw TEXT NOT NULL);", + ) + .map_err(|e| e.to_string())?; + let saved: Option = tx + .query_row( + "SELECT raw FROM desktop_profile WHERE slot = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let raw = match saved { + Some(saved) => saved, + None => { + let profile = DesktopProfile::new( + scope.relay_url.trim_end_matches('/').to_owned(), + uuid::Uuid::new_v4().simple().to_string(), + )?; + let raw = profile.sign(&scope.owner_keys)?.as_json(); + tx.execute("INSERT INTO desktop_profile VALUES (1, ?1)", [&raw]) + .map_err(|e| e.to_string())?; + raw + } + }; + let event = Event::from_json(&raw).map_err(|_| "invalid saved Desktop profile")?; + DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )?; + tx.commit().map_err(|e| e.to_string())?; + Ok(json!({ "event": event })) +} + +/// Prepare or reload the identical owner/community-local installation profile. +#[tauri::command] +pub fn prepare_desktop_profile( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + prepare(&mut open_retention_db(&scope.db_path)?, &scope) +} + +/// Authenticate and decrypt a bounded relay result before exposing any row to UI. +#[tauri::command] +pub fn read_desktop_profiles( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + if events.len() > 100 { + return Err("too many Desktop profiles".into()); + } + let rows: Result, String> = events.iter().map(|event| { + let profile = DesktopProfile::read(event, &scope.owner_keys, &community)?; + Ok(json!({ "id": profile.id, "name": profile.name, "updated": event.created_at.as_secs() })) + }).collect(); + Ok(json!(rows?)) +} + +/// Sign a fresh pulse for the persisted local profile, never an arbitrary host ID. +#[tauri::command] +pub fn prepare_desktop_observation( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + Ok(json!({ "event": prepare_observation(&mut open_retention_db(&scope.db_path)?, &scope)? })) +} + +fn prepare_observation(conn: &mut Connection, scope: &RetentionScope) -> Result { + let saved = prepare(conn, scope)?; + let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + let profile = DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )?; + DesktopObservation::new(profile).sign(&scope.owner_keys) +} + +/// Verify bounded owner-private observations; the UI treats their timestamps as advisory. +#[tauri::command] +pub fn read_desktop_observations( + app: AppHandle, + state: State<'_, AppState>, + owner: String, + community: String, + events: Vec, +) -> Result { + let scope = scope(&app, &state, &owner, &community)?; + if events.len() > 100 { + return Err("too many Desktop observations".into()); + } + let mut rows = Vec::with_capacity(events.len()); + for event in &events { + let observation = DesktopObservation::read(event, &scope.owner_keys, &community)?; + rows.push(json!({ "id": observation.id, "heard": event.created_at.as_secs() })); + } + Ok(json!(rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::scoped_retention_db_path; + + #[test] + fn durable_identity_exact_retry_without_rewrites() { + let dir = tempfile::tempdir().unwrap(); + let owner_keys = nostr::Keys::generate(); + let scope = RetentionScope { + db_path: dir.path().join("one.db"), + relay_url: "wss://one.example".into(), + owner_keys, + }; + let first = prepare(&mut open_retention_db(&scope.db_path).unwrap(), &scope).unwrap(); + let mut reopened = open_retention_db(&scope.db_path).unwrap(); + assert_eq!(prepare(&mut reopened, &scope).unwrap(), first); + // No mutable ACK state: a confirmed or failed publish leaves the same + // signed record available for exact retry, without another native write. + let accepted = prepare(&mut reopened, &scope).unwrap(); + assert_eq!(accepted, first); + assert_eq!(reopened.total_changes(), 0, "no repeated native writes"); + let pulse = prepare_observation(&mut reopened, &scope).unwrap(); + let observation = + DesktopObservation::read(&pulse, &scope.owner_keys, &scope.relay_url).unwrap(); + assert_eq!(first["event"]["tags"][0][1], observation.id); + assert_eq!( + prepare(&mut reopened, &scope).unwrap(), + first, + "pulses never rewrite profiles" + ); + assert_eq!(reopened.total_changes(), 0); + let other = RetentionScope { + db_path: dir.path().join("two.db"), + relay_url: scope.relay_url.clone(), + owner_keys: scope.owner_keys.clone(), + }; + let second = prepare(&mut open_retention_db(&other.db_path).unwrap(), &other).unwrap(); + assert_ne!(first["event"]["tags"], second["event"]["tags"]); + let a = scoped_retention_db_path( + dir.path(), + &scope.relay_url, + &scope.owner_keys.public_key().to_hex(), + ); + assert_ne!( + a, + scoped_retention_db_path( + dir.path(), + "wss://two.example", + &scope.owner_keys.public_key().to_hex() + ) + ); + assert_ne!( + a, + scoped_retention_db_path( + dir.path(), + &scope.relay_url, + &nostr::Keys::generate().public_key().to_hex() + ) + ); + reopened + .execute("UPDATE desktop_profile SET raw = 'corrupt'", []) + .unwrap(); + assert!(prepare(&mut reopened, &scope).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/desktop_stop.rs b/desktop/src-tauri/src/commands/desktop_stop.rs new file mode 100644 index 00000000000..2b15153dfec --- /dev/null +++ b/desktop/src-tauri/src/commands/desktop_stop.rs @@ -0,0 +1,177 @@ +//! Native owner/host validation and ordinary Stop; no keys cross IPC. +use super::desktop_profiles::{prepare, scope}; +use crate::{ + app_state::AppState, + managed_agents::{self, remote_stop, retention::open_retention_db}, +}; +use buzz_core_pkg::{ + desktop_profile::DesktopProfile, + desktop_stop::{StopOutcome, StopResult, StopTarget}, +}; +use nostr::{Event, JsonUtil, PublicKey}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; + +pub(crate) fn local_id( + conn: &mut rusqlite::Connection, + scope: &managed_agents::retention::RetentionScope, +) -> Result { + let saved = prepare(conn, scope)?; + let event: Event = serde_json::from_value(saved["event"].clone()).map_err(|e| e.to_string())?; + Ok(DesktopProfile::read( + &event, + &scope.owner_keys, + scope.relay_url.trim_end_matches('/'), + )? + .id) +} + +/// Persist exact signed bytes before the UI sends a new Stop. No boot replay. +#[tauri::command] +pub fn prepare_desktop_stop( + app: AppHandle, + owner: String, + community: String, + desktop: String, + agent: String, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + let event = StopTarget { + v: 1, + community, + desktop, + agent, + } + .sign(&scope.owner_keys)?; + let conn = open_retention_db(&scope.db_path)?; + // Only the current UI operation needs retry bytes; retained receiver fences + // and results are separate. Nothing automatically drains this slot. + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_outgoing (slot INTEGER PRIMARY KEY CHECK(slot=1), raw TEXT NOT NULL)") + .map_err(|e| e.to_string())?; + conn.execute("INSERT INTO desktop_stop_outgoing VALUES (1, ?1) ON CONFLICT(slot) DO UPDATE SET raw=excluded.raw", [event.as_json()]) + .map_err(|e| e.to_string())?; + Ok(event) +} + +/// Called only for live owner-private delivery. Reopening never fetches commands. +#[tauri::command] +pub async fn receive_desktop_stop( + app: AppHandle, + owner: String, + community: String, + event: Event, +) -> Result, String> { + receive_desktop_stop_for_app(app, owner, community, event).await +} + +pub(crate) async fn receive_desktop_stop_for_app( + app: AppHandle, + owner: String, + community: String, + event: Event, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + let target = StopTarget::read(&event, &scope.owner_keys, &community)?; + let mut conn = open_retention_db(&scope.db_path)?; + let desktop = local_id(&mut conn, &scope)?; + managed_agents::placement::observe(&conn, &event, &scope.owner_keys, &community)?; + if desktop != target.desktop { + return Ok(None); + } + // Local possession alone is insufficient after an account switch: + // verify the stored agent's owner delegation against the request author. + if let Some(raw) = remote_stop::saved_result(&conn, &event.id.to_hex())? { + let saved = Event::from_json(raw).map_err(|e| e.to_string())?; + StopResult::read(&saved, &scope.owner_keys, &event, &community)?; + return Ok(Some(saved)); + } + if managed_agents::placement::desired(&conn, &target.agent)? + .is_some_and(|(host, _)| host == desktop) + { + let result = StopResult { + target, + request: event.id.to_hex(), + outcome: StopOutcome::Unknown, + } + .sign(&scope.owner_keys)?; + remote_stop::save_result(&mut conn, &event.id.to_hex(), &result.as_json())?; + return Ok(Some(result)); + } + let owned = owned_local(&app, &state, &owner, &target.agent)?; + remote_stop::receive( + &mut conn, + &event, + &scope.owner_keys, + &community, + &desktop, + owned, + |target| { + managed_agents::stop_pair_locked( + target.agent.clone(), + community.clone(), + app.clone(), + ) + .map(|_| ()) + }, + ) + }) + .await + .map_err(|e| format!("Desktop Stop task failed: {e}"))? +} + +/// Result queries never dispatch/replay a request. Missing means Unknown. +#[tauri::command] +pub fn read_desktop_stop_results( + app: AppHandle, + owner: String, + community: String, + request: Event, + events: Vec, +) -> Result { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + StopTarget::read(&request, &scope.owner_keys, &community)?; + if events.len() > 16 { + return Err("too many Desktop Stop results".into()); + } + let mut outcome = StopOutcome::Unknown; + for event in events { + let result = StopResult::read(&event, &scope.owner_keys, &request, &community)?; + // Persisted terminal result beats a later Unknown after bounded eviction. + if result.outcome != StopOutcome::Unknown { + outcome = result.outcome; + } + } + Ok(json!(outcome)) +} + +/// Local possession/profile alone never establishes owner authority. +pub(super) fn owned_local( + app: &AppHandle, + state: &AppState, + owner: &str, + agent: &str, +) -> Result { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::storage::load_agent_store(app)?; + Ok(records.iter().find(|r| r.pubkey == agent).is_some_and(|r| { + r.backend == managed_agents::BackendKind::Local + && r.auth_tag + .as_deref() + .and_then(|tag| { + let key = PublicKey::from_hex(agent).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &key).ok() + }) + .is_some_and(|key| key.to_hex() == owner) + })) +} diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9c..144c43c78be 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -18,8 +18,8 @@ use crate::{ managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_global_agent_config, save_managed_agents, - stop_managed_agent_process, sync_managed_agent_processes, validate_global_config, + resolve_effective_agent_env, save_global_agent_config, stop_managed_agent_process, + storage::save_runtime_metadata_batch, sync_managed_agent_processes, validate_global_config, AgentReadiness, BackendKind, GlobalAgentConfig, }, }; @@ -280,7 +280,7 @@ async fn restart_local_agent_on_config_change( ¤t_instance_id(&app_for_stop), ); if sync_changed { - save_managed_agents(&app_for_stop, &records)?; + save_runtime_metadata_batch(&app_for_stop, &records)?; } // Re-check eligibility under lock with current record state. @@ -325,7 +325,7 @@ async fn restart_local_agent_on_config_change( // Stop the process. let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; + save_runtime_metadata_batch(&app_for_stop, &records)?; Ok(runtime_keys) }) @@ -386,7 +386,7 @@ fn persist_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), let record = find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + save_runtime_metadata_batch(app, &records) } /// Pure predicate: should an agent be restarted given resolved readiness and diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a01031..1abb3861fd0 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,12 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod desktop_capabilities; +mod desktop_lifecycle; +mod runtime_configurations; +pub use runtime_configurations::*; +mod desktop_profiles; +pub(crate) mod desktop_stop; mod dms; mod engrams; mod export_util; @@ -92,6 +98,10 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use desktop_capabilities::*; +pub use desktop_lifecycle::*; +pub use desktop_profiles::*; +pub use desktop_stop::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 6a10a1f9ee2..334a4f2ca0e 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -17,6 +17,7 @@ fn make_agent( runtime_pid: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: pubkey.to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e90df637314..d6bccb651aa 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -163,6 +163,7 @@ const AGENT_PUBKEY: &str = "agentpubkeyhex00000000000000000000000000000000000000 /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: AGENT_PUBKEY.to_string(), name: "Local Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 55a64db59bc..06a89099127 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), slug: Some(slug.to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 041a0b91dc9..77565ab3718 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -18,8 +18,9 @@ use crate::{ decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, LOCKED_CARD_REFUSAL, }, - load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, - ManagedAgentRecord, RespondTo, + load_managed_agents, load_personas, save_personas, + storage::save_managed_agents_with_new_keys, + AgentDefinition, ManagedAgentRecord, RespondTo, }, relay::{effective_agent_relay_url, relay_ws_url_with_override}, util::now_iso, @@ -597,6 +598,7 @@ pub async fn confirm_agent_snapshot_import( // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. let record = ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, @@ -666,7 +668,7 @@ pub async fn confirm_agent_snapshot_import( }; records.push(record.clone()); - save_managed_agents(&app, &records)?; + save_managed_agents_with_new_keys(&app, &records)?; // Enqueue the kind:30177 managed-agent event via retention. // (Uses the same pattern as agents.rs::retain_managed_agent_pending diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index abf4bef443d..6d62ea9b24c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; /// persona_id. fn make_definition(slug: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), slug: Some(slug.to_string()), @@ -84,6 +85,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { /// have `slug: None` and link to their definition via `persona_id`. fn make_instance(pubkey: &str, persona_id: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.to_string(), slug: None, persona_id: Some(persona_id.to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs index 43ca23cc822..57a70c6799b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -16,6 +16,7 @@ use crate::managed_agents::agent_snapshot_envelope::{ /// agent-endpoint unlock path resolves exactly as production does. fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: agent.public_key().to_hex(), slug: None, persona_id: Some("locked-test".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 7aedcb25ef5..080fd50188d 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -5,6 +5,7 @@ use super::*; fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: format!("pubkey-{name}"), name: name.to_string(), diff --git a/desktop/src-tauri/src/commands/runtime_configurations.rs b/desktop/src-tauri/src/commands/runtime_configurations.rs new file mode 100644 index 00000000000..9ef93a77a5f --- /dev/null +++ b/desktop/src-tauri/src/commands/runtime_configurations.rs @@ -0,0 +1,143 @@ +//! Local configuration management; no secret values or credentials cross IPC. +use super::{ + desktop_profiles::scope, + desktop_stop::{local_id, owned_local}, +}; +use crate::{ + app_state::AppState, + managed_agents::{ + self, + runtime_configurations::{self, RuntimeConfigurations}, + }, +}; +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeConfigurationView { + configurations: RuntimeConfigurations, + updated_at: String, + host: String, + catalog: Vec, + running: Option, +} + +fn view( + record: &managed_agents::ManagedAgentRecord, + host: String, + community: &str, + owner: &str, + app: &AppHandle, +) -> Result { + let personas = managed_agents::load_personas(app)?; + let global = managed_agents::load_global_agent_config(app)?; + let catalog = + runtime_configurations::catalog(record, &personas, &global, &host, owner, community); + let key = managed_agents::ManagedAgentRuntimeKey::new(&record.pubkey, community)?; + let state = app.state::(); + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let running = match runtimes.get_mut(&key) { + Some(runtime) => { + if runtime + .child + .try_wait() + .map_err(|_| "Running process state is unavailable")? + .is_none() + { + runtime.spawn_config.runtime_configuration.clone() + } else { + None + } + } + None => None, + }; + Ok(RuntimeConfigurationView { + configurations: record.runtime_configurations.get(owner, community), + updated_at: record.updated_at.clone(), + host, + catalog, + running, + }) +} + +#[tauri::command] +pub async fn get_runtime_configurations( + app: AppHandle, + owner: String, + community: String, + agent: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let scope = scope(&app, &state, &owner, &community)?; + if !owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + let host = local_id( + &mut managed_agents::retention::open_retention_db(&scope.db_path)?, + &scope, + )?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = managed_agents::load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == agent) + .ok_or("Agent is unavailable")?; + view(record, host, &community, &owner, &app) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_runtime_configurations( + app: AppHandle, + owner: String, + community: String, + agent: String, + expected_updated_at: String, + configurations: RuntimeConfigurations, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let scope = scope(&app, &state, &owner, &community)?; + if !owned_local(&app, &state, &owner, &agent)? { + return Err("Agent ownership is unavailable on this Desktop".into()); + } + let host = local_id( + &mut managed_agents::retention::open_retention_db(&scope.db_path)?, + &scope, + )?; + configurations.validate()?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = managed_agents::load_managed_agents(&app)?; + let record = managed_agents::find_managed_agent_mut(&mut records, &agent)?; + if record.updated_at != expected_updated_at { + return Err("Agent changed; reload configurations before saving".into()); + } + record + .runtime_configurations + .replace(&owner, &community, &host, configurations)?; + record.updated_at = crate::util::now_iso(); + let result = view(record, host, &community, &owner, &app)?; + managed_agents::save_managed_agents(&app, &records)?; + let _ = app.emit("agents-data-changed", ()); + Ok(result) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 9c57ce12b53..3c1bc98f00b 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -17,8 +17,10 @@ use crate::{ }, managed_agents::{ agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel}, - load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents, - save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord, + load_managed_agents, load_personas, load_teams, load_teams_readonly, save_personas, + save_teams, + storage::save_managed_agents_with_new_keys, + AgentDefinition, ManagedAgentRecord, TeamRecord, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -559,6 +561,7 @@ pub async fn confirm_team_snapshot_import( // Build the ManagedAgentRecord for this member. let record = ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: pubkey.clone(), name: display_name.clone(), display_name: None, @@ -729,7 +732,7 @@ pub async fn confirm_team_snapshot_import( for m in &minted { records.push(m.record.clone()); } - if let Err(e) = save_managed_agents(&app, &records) { + if let Err(e) = save_managed_agents_with_new_keys(&app, &records) { return Err(rollback_agents(e)); } diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 13c7f6ae810..2c8cc4a4071 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -193,6 +193,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { // Build a fake instance record tied to this team+persona. let instance = ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "a".repeat(64), name: "Alice".to_string(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..bb59c405c25 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -552,6 +552,23 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + prepare_desktop_profile, + prepare_desktop_stop, + prepare_desktop_lifecycle, + get_runtime_configurations, + start_runtime_configuration, + save_runtime_configurations, + observe_desktop_placement, + read_desktop_placement, + receive_desktop_lifecycle, + read_desktop_lifecycle_results, + receive_desktop_stop, + read_desktop_stop_results, + read_desktop_profiles, + prepare_desktop_observation, + read_desktop_observations, + prepare_desktop_capabilities, + read_desktop_capabilities, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 85f34260ce7..cae9836446c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -164,6 +164,7 @@ mod tests { fn sample_agent() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agentpubkeyhex".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 131966409b0..590f5f93546 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -366,6 +366,7 @@ mod tests { /// pubkey/nsec pair matters here. fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey, name: "Locked Test".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index da881f64f5a..e628ab7d6cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; /// relevant to snapshot export are filled; the rest use defaults. fn minimal_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "deadbeef".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 9c4568fceb4..b2879cc9351 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -28,6 +28,7 @@ fn buzz_agent() -> &'static KnownAcpRuntime { pub(super) fn record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: "test".to_string(), name: "Test Agent".to_string(), persona_id: None, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 34b4f1496f5..c4a16f22bb2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -68,6 +68,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { fn test_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "test".to_string(), name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 531ae335ce5..f6c8ceff9fd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -285,6 +285,10 @@ pub fn try_record_agent_command( record: &crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], ) -> Result { + if let Some(config) = super::runtime_configurations::selected(record)? { + return presets::command_for_runtime_id(&config.runtime) + .ok_or_else(|| format!("DANGLING_HARNESS_ID:{}", config.runtime)); + } // Explicit pin always wins — if the user set a raw override, honour it. if let Some(pin) = record .agent_command_override @@ -379,7 +383,7 @@ fn command_search_dirs() -> Vec { }) } -fn is_executable_file(path: &Path) -> bool { +pub(super) fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; }; diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs index a716dee9f56..64d496aa07f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell_spawn_probe.rs @@ -1,21 +1,40 @@ //! Test-only counter for login-shell spawn attempts. //! -//! `run_in_login_shell` is the single subprocess-spawning step on the -//! absent-command resolution path, so counting its calls proves whether a -//! cheap discovery re-spawns after a negative resolution was cached. +//! `run_in_login_shell` and the discovery calls under test are synchronous. +//! Count the calling thread's attempts, not unrelated parallel tests' probes; +//! the process-environment lock does not serialize every resolver caller. -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::cell::Cell; -static COUNT: AtomicUsize = AtomicUsize::new(0); +thread_local! { + static COUNT: Cell = const { Cell::new(0) }; +} pub(crate) fn record() { - COUNT.fetch_add(1, Ordering::SeqCst); + COUNT.with(|count| count.set(count.get() + 1)); } pub(crate) fn reset() { - COUNT.store(0, Ordering::SeqCst); + COUNT.with(|count| count.set(0)); } pub(crate) fn count() -> usize { - COUNT.load(Ordering::SeqCst) + COUNT.with(Cell::get) +} + +#[test] +fn counts_this_synchronous_probe_not_parallel_callers() { + reset(); + record(); + std::thread::spawn(|| { + assert_eq!(count(), 0); + record(); + record(); + assert_eq!(count(), 2); + reset(); + }) + .join() + .unwrap(); + assert_eq!(count(), 1); + reset(); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5b..65ca72b94d5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1,5 +1,8 @@ use std::path::PathBuf; +mod record_fixture; +use record_fixture::record_with; + use super::overrides::{divergent_agent_command_override, update_time_agent_command_override}; use super::{ apply_agent_command_update, apply_env_vars_then_effort_transition, classify_runtime, @@ -205,73 +208,6 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests; only resolution inputs vary. -fn record_with( - runtime: Option<&str>, - persona_id: Option<&str>, - override_cmd: Option<&str>, -) -> crate::managed_agents::types::ManagedAgentRecord { - crate::managed_agents::types::ManagedAgentRecord { - description: None, - pubkey: String::new(), - name: "r".to_string(), - persona_id: persona_id.map(str::to_string), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: override_cmd.map(str::to_string), - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - env_vars: std::collections::BTreeMap::new(), - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: Default::default(), - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: runtime.map(str::to_string), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - effort_level: None, - } -} - #[test] fn record_agent_command_own_runtime_wins_over_persona() { // A record with its own runtime never consults the persona list. diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs new file mode 100644 index 00000000000..b05edee0eb2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/record_fixture.rs @@ -0,0 +1,68 @@ +//! Record fixture shared by the existing discovery resolution tests. +/// Minimal record for `record_agent_command` tests; only resolution inputs vary. +pub(super) fn record_with( + runtime: Option<&str>, + persona_id: Option<&str>, + override_cmd: Option<&str>, +) -> crate::managed_agents::types::ManagedAgentRecord { + crate::managed_agents::types::ManagedAgentRecord { + runtime_configurations: Default::default(), + description: None, + pubkey: String::new(), + name: "r".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: override_cmd.map(str::to_string), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index e079c76a1d0..6f2e60748cb 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -15,6 +15,7 @@ pub enum ConfigSource { Definition, Global, InstanceLegacy, + RuntimeConfiguration, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,7 +24,7 @@ pub struct ResolvedField { pub source: ConfigSource, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveAgentConfig { pub model: ResolvedField, pub provider: ResolvedField, @@ -43,17 +44,9 @@ impl EffectiveAgentConfig { /// a blank effective model falls back to "auto", mirroring /// `apply_relay_mesh_env`'s own rule. pub fn relay_mesh_model_id(&self) -> Option { - if self.provider.value.as_deref().map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { - return None; - } - Some( - self.model - .value - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(), + super::resolved_relay_mesh_model_id( + self.provider.value.as_deref(), + self.model.value.as_deref(), ) } } @@ -249,7 +242,7 @@ pub fn resolve_effective_config( definitions: &[AgentDefinition], global: &GlobalAgentConfig, ) -> EffectiveConfigResult { - match &record.persona_id { + let mut result = match &record.persona_id { Some(pid) => match definitions.iter().find(|d| d.id == *pid) { Some(def) => EffectiveConfigResult::Resolved(resolve_linked(def, global)), None => EffectiveConfigResult::OrphanedInstance { @@ -258,7 +251,17 @@ pub fn resolve_effective_config( }, }, None => EffectiveConfigResult::Resolved(resolve_definition_less(record, global)), + }; + // Persona still owns identity/instructions, not an explicitly selected runtime. + if let (EffectiveConfigResult::Resolved(config), Ok(Some(selected))) = + (&mut result, super::runtime_configurations::selected(record)) + { + config.model.value = Some(selected.model.clone()); + config.model.source = ConfigSource::RuntimeConfiguration; + config.provider.source = ConfigSource::RuntimeConfiguration; + config.provider.value = selected.provider.clone(); } + result } pub fn resolve_effective_model_provider_pair( @@ -283,6 +286,11 @@ pub fn resolve_effective_model_provider_pair( /// never spawns (see `require_resolved`), so it never needs a mesh preflight /// either; the caller's own orphan handling downstream is unaffected, this /// just avoids tripping mesh bootstrap for a start that will be refused. +/// +/// Bound only by `effective_config/tests.rs` now — production mesh preflights +/// resolve through `PreparedLaunch`'s captured effective config — so the +/// resolver stays as a `cfg(test)` seam for the mesh-model regression tests. +#[cfg(test)] pub fn resolve_effective_relay_mesh_model_id( record: &ManagedAgentRecord, definitions: &[AgentDefinition], diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 1ed44ace946..46e86ad9e89 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -41,6 +41,7 @@ fn record( ) -> ManagedAgentRecord { use crate::managed_agents::{BackendKind, RespondTo}; ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent-pk".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 5f39b7b75f2..71887ba2130 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -299,6 +299,7 @@ fn default_global_config_serializes_all_fields() { fn bare_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "agent".to_string(), name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a66f9c75ba2..070c23a735b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -27,16 +27,19 @@ pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; +pub(crate) mod placement; #[cfg(windows)] mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; +pub(crate) mod remote_stop; mod repos; mod restore; pub mod retention; mod runtime; mod runtime_commands; +pub(crate) mod runtime_configurations; mod runtime_types; mod session_policy; pub(crate) mod snapshot_avatar; @@ -49,27 +52,45 @@ pub(crate) use team_repair::team_persona_key; mod teams; mod types; -// Shared lock for tests that call `lock_path_mutex` or `lock_env_mutex`. -// Both helpers delegate here so any two tests using either helper are mutually -// exclusive with each other. Tests in other modules that maintain their own -// independent locks (app_state_tests, agent_config_tests, reader_tests) are -// NOT in this domain and are not covered by this mutex. +// Shared lock for tests that call `lock_path_mutex`, `lock_path_mutex_async`, +// or `lock_env_mutex`. All helpers delegate here so any two tests using any +// of them are mutually exclusive with each other. Tests in other modules that +// maintain their own independent locks (app_state_tests, agent_config_tests, +// reader_tests) are NOT in this domain and are not covered by this mutex. +// +// The lock is a `tokio::sync::Mutex` (same convention as buzz-db's +// `POOL_METRICS_TEST_LOCK`) so async tests can hold the serialization guard +// across `.await` points without tripping `clippy::await_holding_lock`, while +// sync tests acquire it with `blocking_lock()`. One lock domain keeps sync +// and async env-mutating tests mutually exclusive; the guard scope (the whole +// test body) is unchanged, so shared-state isolation is preserved exactly. #[cfg(test)] -static PROCESS_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); +static PROCESS_ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -// Acquires the shared process-env lock. Call from any test in this module that -// reads, writes, or removes a process-global environment variable (including PATH). +// Acquires the shared process-env lock from a synchronous test. Call from any +// sync test that reads, writes, or removes a process-global environment +// variable (including PATH). Panics if called from inside an async context — +// async tests must use `lock_path_mutex_async` instead. #[cfg(test)] -pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { - PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +pub(crate) fn lock_path_mutex() -> tokio::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.blocking_lock() +} + +// Async twin of `lock_path_mutex`: the same single lock domain, acquired in +// `async fn` tests. Hold the returned guard for the whole test body, across +// `.await` points, exactly where the sync guard was previously held — the +// guard keeps every env-mutating test (sync or async) mutually exclusive. +#[cfg(test)] +pub(crate) async fn lock_path_mutex_async() -> tokio::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.lock().await } // Delegates to the same lock as `lock_path_mutex`. Tests using either helper // are mutually exclusive with each other; PATH and env-key mutations that go // through these helpers cannot race. #[cfg(test)] -pub(crate) fn lock_env_mutex() -> std::sync::MutexGuard<'static, ()> { - PROCESS_ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) +pub(crate) fn lock_env_mutex() -> tokio::sync::MutexGuard<'static, ()> { + PROCESS_ENV_MUTEX.blocking_lock() } pub use backend::*; @@ -106,8 +127,8 @@ pub use runtime::*; pub use runtime_commands::*; pub use runtime_types::*; pub(crate) use session_policy::{ - acp_session_policy, apply_app_acp_session_policy_env, insert_acp_session_policy_env, - AcpSessionPolicy, ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR, + acp_session_policy, insert_acp_session_policy_env, AcpSessionPolicy, + ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR, }; pub use storage::*; pub use teams::*; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c712b2525d4..a63223568e8 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -38,6 +38,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index f0806c8bc04..a408ba9bb04 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -64,6 +64,7 @@ mod tests { fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: String::new(), name: "r".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 9367ad463e2..8799a1ca7b1 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -5,6 +5,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// state right after creation, before any snapshot apply. pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".repeat(64), name: "agent".into(), diff --git a/desktop/src-tauri/src/managed_agents/placement.rs b/desktop/src-tauri/src/managed_agents/placement.rs new file mode 100644 index 00000000000..53b81ca6eee --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/placement.rs @@ -0,0 +1,228 @@ +//! Compact intent, separate from one-shot execution. No history replay. +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Observation, Outcome, Request, ResultMessage}, + desktop_stop::StopTarget, + kind::KIND_DESKTOP_STOP, +}; +use nostr::{Event, JsonUtil, Keys}; +use rusqlite::{params, Connection, OptionalExtension}; + +pub(crate) fn schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS desktop_placement ( + agent TEXT NOT NULL, slot TEXT NOT NULL, host TEXT NOT NULL, stamp INTEGER NOT NULL, + id TEXT NOT NULL, PRIMARY KEY(agent,slot)); + CREATE TABLE IF NOT EXISTS desktop_lifecycle_admission ( + agent TEXT NOT NULL, host TEXT NOT NULL, action TEXT NOT NULL, stamp INTEGER NOT NULL, + id TEXT NOT NULL, PRIMARY KEY(agent,host,action)); + CREATE TABLE IF NOT EXISTS desktop_lifecycle_results ( + id TEXT PRIMARY KEY, raw TEXT NOT NULL);", + ) + .map_err(|e| e.to_string()) +} + +/// Start's shared max and each host's Stop max are sufficient statistics. +/// Stopping newest Start never falls back to an earlier host. +pub(crate) fn observe( + conn: &Connection, + event: &Event, + keys: &Keys, + community: &str, +) -> Result { + let (target, slot) = if event.kind.as_u16() as u32 == KIND_DESKTOP_STOP { + let target = StopTarget::read(event, keys, community)?; + let slot = format!("stop:{}", target.desktop); + (target, slot) + } else { + let request = Request::read(event, keys, community)?; + if request.action != Action::Start { + return Ok(request.target.agent); + } + (request.target, "start".into()) + }; + schema(conn)?; + conn.execute( + "INSERT INTO desktop_placement VALUES (?1,?2,?3,?4,?5) + ON CONFLICT(agent,slot) DO UPDATE SET host=excluded.host,stamp=excluded.stamp,id=excluded.id + WHERE excluded.stamp > desktop_placement.stamp OR + (excluded.stamp = desktop_placement.stamp AND excluded.id < desktop_placement.id)", + params![ + target.agent, + slot, + target.desktop, + event.created_at.as_secs(), + event.id.to_hex() + ], + ) + .map_err(|e| e.to_string())?; + Ok(target.agent) +} + +type Row = (String, u64, String); +fn row(conn: &Connection, agent: &str, slot: &str) -> Result, String> { + schema(conn)?; + conn.query_row( + "SELECT host,stamp,id FROM desktop_placement WHERE agent=?1 AND slot=?2", + params![agent, slot], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .map_err(|e| e.to_string()) +} +fn newer(a: &Row, b: &Row) -> bool { + a.1 > b.1 || (a.1 == b.1 && a.2 < b.2) +} + +/// Some Start remains desired, or none. Intent does not establish process state. +pub(crate) fn desired(conn: &Connection, agent: &str) -> Result, String> { + let Some(start) = row(conn, agent, "start")? else { + return Ok(None); + }; + if row(conn, agent, &format!("stop:{}", start.0))?.is_some_and(|stop| newer(&stop, &start)) { + return Ok(None); + } + Ok(Some((start.0, start.2))) +} + +/// Unknown preserves existing local behavior; known supersession blocks every spawn. +pub(crate) fn blocked(conn: &Connection, agent: &str, host: &str) -> Result { + let start = row(conn, agent, "start")?; + let stop = row(conn, agent, &format!("stop:{host}"))?; + Ok(match (start, stop) { + (None, Some(_)) => true, + (None, None) => false, + (Some(start), Some(stop)) if newer(&stop, &start) => true, + (Some(start), _) => start.0 != host, + }) +} + +/// Durable high-water marks are never evicted with diagnostic/result history. +pub(crate) fn admit(conn: &Connection, event: &Event, request: &Request) -> Result { + schema(conn)?; + let action = match request.action { + Action::Start => "start", + Action::Restart => "restart", + Action::Status => "status", + Action::Catalog => "catalog", + Action::Preflight => "preflight", + }; + let changed = conn.execute("INSERT INTO desktop_lifecycle_admission VALUES (?1,?2,?3,?4,?5) + ON CONFLICT(agent,host,action) DO UPDATE SET stamp=excluded.stamp,id=excluded.id + WHERE excluded.stamp > desktop_lifecycle_admission.stamp OR + (excluded.stamp = desktop_lifecycle_admission.stamp AND excluded.id < desktop_lifecycle_admission.id)", + params![request.target.agent,request.target.desktop,action,event.created_at.as_secs(),event.id.to_hex()]).map_err(|e| e.to_string())?; + Ok(changed == 1) +} +pub(crate) fn saved(conn: &Connection, id: &str) -> Result, String> { + schema(conn)?; + let raw: Option = conn + .query_row( + "SELECT raw FROM desktop_lifecycle_results WHERE id=?1", + [id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + raw.map(|s| Event::from_json(s).map_err(|e| e.to_string())) + .transpose() +} +pub(crate) fn save(conn: &mut Connection, id: &str, event: &Event) -> Result<(), String> { + schema(conn)?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute( + "INSERT OR IGNORE INTO desktop_lifecycle_results VALUES (?1,?2)", + params![id, event.as_json()], + ) + .map_err(|e| e.to_string())?; + tx.execute("DELETE FROM desktop_lifecycle_results WHERE rowid NOT IN (SELECT rowid FROM desktop_lifecycle_results ORDER BY rowid DESC LIMIT 256)", []).map_err(|e|e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests; + +pub(crate) fn has_start(conn: &Connection, agent: &str) -> Result { + Ok(row(conn, agent, "start")?.is_some()) +} +pub(crate) fn latest_start( + conn: &Connection, + agent: &str, +) -> Result, String> { + Ok(row(conn, agent, "start")?.map(|(host, _, id)| (host, id))) +} + +/// A newer local Stop invalidates stale Restart even after a subsequent Start. +pub(crate) fn stale_restart( + conn: &Connection, + event: &Event, + request: &Request, +) -> Result { + let command = ( + request.target.desktop.clone(), + event.created_at.as_secs(), + event.id.to_hex(), + ); + Ok(row( + conn, + &request.target.agent, + &format!("stop:{}", request.target.desktop), + )? + .is_some_and(|stop| newer(&stop, &command))) +} + +/// Authenticate and consume before effects; crashes and evicted results never +/// turn an exact retry into a fresh launch or Restart. +pub(crate) fn receive( + conn: &mut Connection, + event: &Event, + keys: &Keys, + community: &str, + desktop: &str, + owned: bool, + effect: impl FnOnce(&Connection, &Request) -> Result<(Outcome, Option), String>, +) -> Result, String> { + let request = Request::read(event, keys, community)?; + observe(conn, event, keys, community)?; + if request.target.desktop != desktop { + return Ok(None); + } + if let Some(saved) = saved(conn, &event.id.to_hex())? { + ResultMessage::read(&saved, keys, event, community)?; + return Ok(Some(saved)); + } + let probe = matches!(request.action, Action::Catalog | Action::Preflight); + let (outcome, observation) = if !owned { + (Outcome::Failed, None) + } else if (!probe && !admit(conn, event, &request)?) + || (!matches!( + request.action, + Action::Status | Action::Catalog | Action::Preflight + ) && (blocked(conn, &request.target.agent, desktop)? + || (request.action == Action::Restart && stale_restart(conn, event, &request)?) + || (request.action == Action::Start + && desired(conn, &request.target.agent)?.map(|(_, id)| id) + != Some(event.id.to_hex())))) + || (request.action != Action::Status + && nostr::Timestamp::now().as_secs() >= event.created_at.as_secs().saturating_add(30)) + { + (Outcome::Unknown, None) + } else { + effect(conn, &request).unwrap_or(( + if request.action == Action::Status { + Outcome::Unknown + } else { + Outcome::Failed + }, + None, + )) + }; + let result = ResultMessage { + request, + id: event.id.to_hex(), + outcome, + observation, + } + .sign(keys)?; + save(conn, &event.id.to_hex(), &result)?; + Ok(Some(result)) +} diff --git a/desktop/src-tauri/src/managed_agents/placement/tests.rs b/desktop/src-tauri/src/managed_agents/placement/tests.rs new file mode 100644 index 00000000000..54bd018fcf4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/placement/tests.rs @@ -0,0 +1,364 @@ +use super::*; +use buzz_core_pkg::desktop_lifecycle::{Outcome, ResultMessage}; +use nostr::Timestamp; +fn event(keys: &Keys, host: &str, start: bool, stamp: u64) -> Event { + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: host.repeat(32), + agent: keys.public_key().to_hex(), + }; + let event = if start { + Request { + target, + action: Action::Start, + observed: None, + configuration: None, + cursor: None, + } + .sign(keys) + .unwrap() + } else { + target.sign(keys).unwrap() + }; + nostr::EventBuilder::new(event.kind, event.content) + .tags(event.tags) + .custom_created_at(Timestamp::from(stamp)) + .sign_with_keys(keys) + .unwrap() +} +#[test] +fn opposite_arrival_and_scoped_stops_converge_without_resurrection() { + let keys = Keys::generate(); + let agent = keys.public_key().to_hex(); + let events = [ + event(&keys, "a", true, 1), + event(&keys, "b", true, 2), + event(&keys, "a", false, 3), + ]; + for order in [vec![0, 1, 2], vec![2, 0, 1], vec![1, 2, 0]] { + let conn = Connection::open_in_memory().unwrap(); + for i in order { + observe(&conn, &events[i], &keys, "wss://one.example").unwrap(); + } + assert_eq!( + desired(&conn, &agent).unwrap(), + Some(("b".repeat(32), events[1].id.to_hex())) + ); + assert!(blocked(&conn, &agent, &"a".repeat(32)).unwrap()); + assert!(!blocked(&conn, &agent, &"b".repeat(32)).unwrap()); + observe( + &conn, + &event(&keys, "b", false, 4), + &keys, + "wss://one.example", + ) + .unwrap(); + assert_eq!(desired(&conn, &agent).unwrap(), None); + assert!(blocked(&conn, &agent, &"b".repeat(32)).unwrap()); + observe(&conn, &events[0], &keys, "wss://one.example").unwrap(); + assert_eq!(desired(&conn, &agent).unwrap(), None); + } +} +#[test] +fn same_second_lower_id_and_future_timestamp_are_authority() { + let keys = Keys::generate(); + let conn = Connection::open_in_memory().unwrap(); + let a = event(&keys, "a", true, 1000); + let b = event(&keys, "b", true, 1000); + for e in [&a, &b, &a] { + observe(&conn, e, &keys, "wss://one.example").unwrap(); + } + let winner = if a.id < b.id { &a } else { &b }; + assert_eq!( + desired(&conn, &keys.public_key().to_hex()) + .unwrap() + .unwrap() + .1, + winner.id.to_hex() + ); + observe( + &conn, + &event(&keys, "c", true, 999), + &keys, + "wss://one.example", + ) + .unwrap(); + assert_eq!( + desired(&conn, &keys.public_key().to_hex()) + .unwrap() + .unwrap() + .1, + winner.id.to_hex() + ); +} +#[test] +fn consumption_survives_restart_and_result_eviction() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("journal.db"); + let keys = Keys::generate(); + let event = event(&keys, "a", true, 1); + let request = Request::read(&event, &keys, "wss://one.example").unwrap(); + let mut conn = Connection::open(&path).unwrap(); + assert!(admit(&conn, &event, &request).unwrap()); + for i in 0..258 { + let result = ResultMessage { + request: request.clone(), + id: event.id.to_hex(), + outcome: Outcome::Unknown, + observation: None, + } + .sign(&keys) + .unwrap(); + save(&mut conn, &i.to_string(), &result).unwrap(); + } + drop(conn); + let conn = Connection::open(&path).unwrap(); + assert!(!admit(&conn, &event, &request).unwrap()); + assert!(saved(&conn, "0").unwrap().is_none()); + assert!(admit(&conn, &super::tests::event(&keys, "a", true, 2), &request).unwrap()); +} + +#[test] +fn receiver_consumes_before_effect_and_never_repeats_restart() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let start = event(&keys, "a", true, 10); + let start_request = Request::read(&start, &keys, "wss://one.example").unwrap(); + observe(&conn, &start, &keys, "wss://one.example").unwrap(); + let restart = Request { + action: Action::Restart, + observed: Some("f".repeat(64)), + configuration: None, + cursor: None, + ..start_request + } + .sign(&keys) + .unwrap(); + let mut effects = 0; + let result = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |conn, request| { + assert!( + !admit(conn, &restart, request).unwrap(), + "effect must see durable consumption" + ); + effects += 1; + Ok((Outcome::Running, None)) + }, + ) + .unwrap() + .unwrap(); + let retry = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("duplicate effect"), + ) + .unwrap() + .unwrap(); + assert_eq!(result, retry); + assert_eq!(effects, 1); + conn.execute("DELETE FROM desktop_lifecycle_results", []) + .unwrap(); + let unknown = receive( + &mut conn, + &restart, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("evicted effect"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&unknown, &keys, &restart, "wss://one.example") + .unwrap() + .outcome, + Outcome::Unknown + ); +} + +#[test] +fn receiver_rejects_wrong_owner_route_and_superseded_start() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let first = event(&keys, "a", true, 1); + let newer = event(&keys, "a", true, 2); + observe(&conn, &newer, &keys, "wss://one.example").unwrap(); + assert!(receive( + &mut conn, + &first, + &Keys::generate(), + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("owner") + ) + .is_err()); + assert!(receive( + &mut conn, + &first, + &keys, + "wss://one.example", + &"b".repeat(32), + true, + |_, _| panic!("route") + ) + .unwrap() + .is_none()); + let result = receive( + &mut conn, + &first, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("stale Start"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &first, "wss://one.example") + .unwrap() + .outcome, + Outcome::Unknown + ); + let denied = receive( + &mut conn, + &newer, + &keys, + "wss://one.example", + &"a".repeat(32), + false, + |_, _| panic!("unowned"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&denied, &keys, &newer, "wss://one.example") + .unwrap() + .outcome, + Outcome::Failed + ); +} + +#[test] +fn receiver_failure_is_saved_without_reinvoking_launch() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let start = event(&keys, "a", true, Timestamp::now().as_secs()); + let result = receive( + &mut conn, + &start, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| Err("native error with private path".into()), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &start, "wss://one.example") + .unwrap() + .outcome, + Outcome::Failed + ); + let retry = receive( + &mut conn, + &start, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("retry"), + ) + .unwrap() + .unwrap(); + assert_eq!(retry, result); +} + +#[test] +fn probes_never_write_placement_or_admission_and_retries_are_exact() { + let keys = Keys::generate(); + for action in [Action::Catalog, Action::Preflight] { + let mut conn = Connection::open_in_memory().unwrap(); + let reference = buzz_core_pkg::desktop_lifecycle::RuntimeConfigurationRef { + id: uuid::Uuid::new_v4().to_string(), + revision: uuid::Uuid::new_v4().to_string(), + }; + let mut request = + Request::read(&event(&keys, "a", true, 1), &keys, "wss://one.example").unwrap(); + request.action = action; + request.configuration = (action == Action::Preflight).then_some(reference); + let event = request.sign(&keys).unwrap(); + let result = receive( + &mut conn, + &event, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |conn, _| { + for table in ["desktop_placement", "desktop_lifecycle_admission"] { + assert_eq!( + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + } + Ok((Outcome::Ineligible, None)) + }, + ) + .unwrap() + .unwrap(); + let retry = receive( + &mut conn, + &event, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("repeat probe"), + ) + .unwrap() + .unwrap(); + assert_eq!(result, retry); + assert_eq!(latest_start(&conn, &request.target.agent).unwrap(), None); + } +} + +#[test] +fn expired_start_cannot_complete_a_delayed_preflight() { + let keys = Keys::generate(); + let mut conn = Connection::open_in_memory().unwrap(); + let event = event(&keys, "a", true, Timestamp::now().as_secs() - 31); + let result = receive( + &mut conn, + &event, + &keys, + "wss://one.example", + &"a".repeat(32), + true, + |_, _| panic!("expired effect"), + ) + .unwrap() + .unwrap(); + assert_eq!( + ResultMessage::read(&result, &keys, &event, "wss://one.example") + .unwrap() + .outcome, + Outcome::Unknown + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 88cc7884c41..1470eb4f7e6 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -47,12 +47,13 @@ use crate::managed_agents::{ discovery::{known_acp_runtime, KnownAcpRuntime}, env_vars::merged_user_env, global_config::GlobalAgentConfig, - normalize_agent_args, types::{AcpAvailabilityStatus, AgentDefinition, ManagedAgentRecord}, }; mod cli_login; pub(crate) mod cli_probe; +mod descriptor; +pub(crate) use descriptor::{resolve_effective_harness_descriptor, EffectiveHarnessDescriptor}; // ── EffectiveAgentEnv ───────────────────────────────────────────────────────── @@ -78,101 +79,6 @@ pub(crate) struct EffectiveAgentEnv { pub effective_command: String, } -// ── Typed effective-harness descriptor ─────────────────────────────────────── -// -// A single owned type that fully describes what a spawn would run. Produced -// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_snapshot, build_managed_agent_summary, get_agent_models, and -// agent_readiness — so the harness-definition lookup and arg/env resolution -// happen exactly once, in one place. - -/// The complete effective description of a harness spawn: resolved command, -/// args, and layered env. This is the single source of truth for what will -/// actually run — computed once and shared across every consumer that needs -/// the effective values. -#[derive(Debug, Clone)] -pub(crate) struct EffectiveHarnessDescriptor { - /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). - /// Used for `known_acp_runtime` lookup and hashing. - pub command: String, - /// Normalized effective args. Instance args win when non-empty; otherwise - /// the harness definition's args apply. - pub args: Vec, - /// The full layered process env: baked floor → runtime metadata → definition - /// env → global → persona → agent. - pub env: BTreeMap, -} - -/// Resolve the complete harness descriptor from a record + context — the single -/// authoritative path for command, args, and env. -/// -/// This is the only place where harness-definition lookup and arg/env layering -/// happen; spawn, hash, summary, and both model-probe paths all consume this. -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked -/// persona) references a runtime id that no longer exists in the registry — -/// the same typed error produced by `try_record_agent_command`. Callers that -/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) -/// propagate the error; callers that degrade gracefully may use -/// `.unwrap_or_else(|_| …)`. -/// -/// Does NOT require an `AppHandle` so it is fully unit-testable. -/// -/// # Arguments -/// * `record` — the managed agent record -/// * `personas` — all current personas (for command/env resolution) -/// * `global` — global agent config defaults -pub(crate) fn resolve_effective_harness_descriptor( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Result { - let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; - let runtime_meta = known_acp_runtime(&effective_command); - - // Look up the harness definition once — used for both args and env. - // Resolution order: record.runtime → persona.runtime → "". - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; - - // Args: explicit non-empty instance args win; otherwise use definition args. - let args = { - let record_args = record.agent_args.clone(); - let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); - if instance_has_args { - normalize_agent_args(&effective_command, record_args) - } else if let Some(ref def) = harness_def { - normalize_agent_args(&effective_command, def.args.clone()) - } else { - normalize_agent_args(&effective_command, record_args) - } - }; - - // Env: full layered resolution (same as resolve_effective_agent_env). - // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); - - Ok(EffectiveHarnessDescriptor { - command: effective_command, - args, - env: effective_env.env, - }) -} - /// Assemble the effective agent env from a record, personas, optional /// known-runtime metadata, and the global agent config defaults — without an /// `AppHandle` so it is fully unit-testable. @@ -1493,6 +1399,7 @@ mod tests { ); // Minimal record: only the fields resolve_effective_agent_env reads. let record = crate::managed_agents::types::ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs new file mode 100644 index 00000000000..cc48673b16f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness/descriptor.rs @@ -0,0 +1,116 @@ +//! Immutable harness descriptor resolution shared by launch and inspection. +use super::{resolve_effective_agent_env_with_def, ManagedAgentRecord}; +use crate::managed_agents::{discovery::known_acp_runtime, normalize_agent_args}; +use std::collections::BTreeMap; + +// ── Typed effective-harness descriptor ─────────────────────────────────────── +// +// A single owned type that fully describes what a spawn would run. Produced +// by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, +// spawn_snapshot, build_managed_agent_summary, get_agent_models, and +// agent_readiness — so the harness-definition lookup and arg/env resolution +// happen exactly once, in one place. + +/// The complete effective description of a harness spawn: resolved command, +/// args, and layered env. This is the single source of truth for what will +/// actually run — computed once and shared across every consumer that needs +/// the effective values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EffectiveHarnessDescriptor { + /// The raw effective command string (e.g. `"buzz-agent"`, `"my-acp-agent"`). + /// Used for `known_acp_runtime` lookup and hashing. + pub command: String, + /// Normalized effective args. Instance args win when non-empty; otherwise + /// the harness definition's args apply. + pub args: Vec, + /// The full layered process env: baked floor → runtime metadata → definition + /// env → global → persona → agent. + pub env: BTreeMap, +} + +/// Resolve the complete harness descriptor from a record + context — the single +/// authoritative path for command, args, and env. +/// +/// This is the only place where harness-definition lookup and arg/env layering +/// happen; spawn, hash, summary, and both model-probe paths all consume this. +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` when the record (or its linked +/// persona) references a runtime id that no longer exists in the registry — +/// the same typed error produced by `try_record_agent_command`. Callers that +/// cannot meaningfully continue with a dangling id (e.g. `spawn_agent_child`) +/// propagate the error; callers that degrade gracefully may use +/// `.unwrap_or_else(|_| …)`. +/// +/// Does NOT require an `AppHandle` so it is fully unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record +/// * `personas` — all current personas (for command/env resolution) +/// * `global` — global agent config defaults +pub(crate) fn resolve_effective_harness_descriptor( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + // A transient projection, never persisted back onto the stable agent/persona. + let projected; + let record = + if let Some(config) = crate::managed_agents::runtime_configurations::selected(record)? { + projected = { + let mut copy = record.clone(); + copy.runtime = Some(config.runtime.clone()); + copy.agent_args.clear(); + copy.agent_command_override = None; + copy + }; + &projected + } else { + record + }; + let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; + let runtime_meta = known_acp_runtime(&effective_command); + + // Look up the harness definition once — used for both args and env. + // Resolution order: record.runtime → persona.runtime → "". + let harness_def = { + let runtime_id = record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + }; + + // Args: explicit non-empty instance args win; otherwise use definition args. + let args = { + let record_args = record.agent_args.clone(); + let instance_has_args = record_args.iter().any(|a| !a.trim().is_empty()); + if instance_has_args { + normalize_agent_args(&effective_command, record_args) + } else if let Some(ref def) = harness_def { + normalize_agent_args(&effective_command, def.args.clone()) + } else { + normalize_agent_args(&effective_command, record_args) + } + }; + + // Env: full layered resolution (same as resolve_effective_agent_env). + // Pass harness_def directly to avoid a second lookup. + let effective_env = + resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + + let mut descriptor = EffectiveHarnessDescriptor { + command: effective_command, + args, + env: effective_env.env, + }; + crate::managed_agents::runtime_configurations::apply_descriptor(record, &mut descriptor)?; + Ok(descriptor) +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 3858212bbba..f8aaa5b4161 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,5 +1,23 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; +/// Classify resolved configuration, never a next-launch selection. Used by +/// preflight/spawn and by recovery over the actual running spawn snapshot. +pub(crate) fn resolved_relay_mesh_model_id( + provider: Option<&str>, + model: Option<&str>, +) -> Option { + if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return None; + } + Some( + model + .map(str::trim) + .filter(|m| !m.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_owned(), + ) +} + pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; /// Stored value for "let the mesh decide", kept as the user-facing word. pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; diff --git a/desktop/src-tauri/src/managed_agents/remote_stop.rs b/desktop/src-tauri/src/managed_agents/remote_stop.rs new file mode 100644 index 00000000000..5fb970f147e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_stop.rs @@ -0,0 +1,519 @@ +//! Durable Stop admission. Compact outcomes never compact the per-agent fence. +use buzz_core_pkg::desktop_stop::{StopOutcome, StopResult, StopTarget}; +use nostr::{Event, JsonUtil, Keys}; +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use tauri::{AppHandle, Manager}; + +use super::retention::{open_retention_db, scoped_retention_db_path}; +use super::ManagedAgentRuntimeKey; + +const HISTORY_LIMIT: i64 = 256; + +fn schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch("CREATE TABLE IF NOT EXISTS desktop_stop_fence ( + agent TEXT PRIMARY KEY, stamp INTEGER NOT NULL, event_id TEXT NOT NULL, blocked INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS desktop_stop_results ( + id TEXT PRIMARY KEY, raw TEXT NOT NULL);") + .map_err(|e| e.to_string()) +} + +/// Persist admission before effect; duplicates/interruption never repeat Stop. +pub(crate) fn admit( + conn: &mut Connection, + request: &Event, + target: &StopTarget, +) -> Result { + schema(conn)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + let previous: Option<(u64, String)> = tx + .query_row( + "SELECT stamp, event_id FROM desktop_stop_fence WHERE agent = ?1", + [&target.agent], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + let id = request.id.to_hex(); + let stamp = request.created_at.as_secs(); + if previous.is_some_and(|(time, key)| time > stamp || (time == stamp && key <= id)) { + return Ok(false); + } + tx.execute("INSERT INTO desktop_stop_fence VALUES (?1, ?2, ?3, 1) + ON CONFLICT(agent) DO UPDATE SET stamp=excluded.stamp, event_id=excluded.event_id, blocked=1", + params![target.agent, stamp, id]).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(true) +} + +pub(crate) fn saved_result(conn: &Connection, id: &str) -> Result, String> { + schema(conn)?; + conn.query_row( + "SELECT raw FROM desktop_stop_results WHERE id=?1", + [id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string()) +} + +pub(crate) fn save_result(conn: &mut Connection, id: &str, raw: &str) -> Result<(), String> { + schema(conn)?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute( + "INSERT OR IGNORE INTO desktop_stop_results VALUES (?1, ?2)", + params![id, raw], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "DELETE FROM desktop_stop_results WHERE rowid NOT IN + (SELECT rowid FROM desktop_stop_results ORDER BY rowid DESC LIMIT ?1)", + [HISTORY_LIMIT], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string()) +} + +/// Authenticate and durably consume a live request before invoking ordinary Stop. +/// The caller holds the runtime transition lock across this entire operation. +pub(crate) fn receive( + conn: &mut Connection, + request: &Event, + keys: &Keys, + community: &str, + desktop: &str, + owned: bool, + stop: impl FnOnce(&StopTarget) -> Result<(), String>, +) -> Result, String> { + let target = StopTarget::read(request, keys, community)?; + if target.desktop != desktop { + return Ok(None); + } + let id = request.id.to_hex(); + if let Some(raw) = saved_result(conn, &id)? { + let result = Event::from_json(raw).map_err(|e| e.to_string())?; + StopResult::read(&result, keys, request, community)?; + return Ok(Some(result)); + } + let outcome = if !owned { + StopOutcome::Failed + } else if admit(conn, request, &target)? { + outcome(stop(&target)) + } else { + StopOutcome::Unknown + }; + let result = StopResult { + target, + request: id.clone(), + outcome, + } + .sign(keys)?; + save_result(conn, &id, &result.as_json())?; + Ok(Some(result)) +} + +/// Explicit local Start captures the Stop fence before its asynchronous preflight. +/// Automatic starts and Restart continuations never receive this permission. +pub(crate) struct ResumeTicket { + previous: Option, +} + +pub(crate) fn capture_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + community: &str, + owner: &str, +) -> Result { + let conn = connection(app, community, owner)?; + schema(&conn)?; + let previous = conn + .query_row( + "SELECT event_id FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(ResumeTicket { previous }) +} + +fn connection( + app: &AppHandle, + community: &str, + owner: &str, +) -> Result { + let path = scoped_retention_db_path(&super::managed_agents_base_dir(app)?, community, owner); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + open_retention_db(&path) +} + +/// Every ordinary spawn passes here, including restore/config/reconcile. +/// Caller holds the existing transition lock through child registration. +pub(crate) fn check_launch( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + community: &str, + owner: Option<&str>, + resume: Option<&ResumeTicket>, +) -> Result<(), String> { + let state = app.state::(); + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } + let current_owner = state.signing_keys()?.public_key().to_hex(); + if owner != Some(current_owner.as_str()) { + return Err("Desktop launch owner changed".into()); + } + let conn = connection(app, community, ¤t_owner)?; + schema(&conn)?; + let scope = super::retention::RetentionScope { + db_path: scoped_retention_db_path( + &super::managed_agents_base_dir(app)?, + community, + ¤t_owner, + ), + relay_url: community.to_owned(), + owner_keys: state.signing_keys()?, + }; + let mut local_conn = connection(app, community, ¤t_owner)?; + let host = crate::commands::desktop_stop::local_id(&mut local_conn, &scope)?; + if super::placement::blocked(&conn, &key.pubkey, &host)? + && (resume.is_none() || super::placement::has_start(&conn, &key.pubkey)?) + { + return Err("This Desktop is no longer the selected running destination. Use explicit Start on this Desktop.".into()); + } + let row: Option<(String, bool)> = conn + .query_row( + "SELECT event_id, blocked FROM desktop_stop_fence WHERE agent=?1", + [&key.pubkey], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + allow_launch(row.as_ref(), resume) +} + +fn allow_launch(row: Option<&(String, bool)>, resume: Option<&ResumeTicket>) -> Result<(), String> { + if let Some(ticket) = resume { + if ticket.previous.as_ref() != row.map(|(id, _)| id) { + return Err("A newer Stop interrupted this Start".into()); + } + } else if row.is_some_and(|(_, blocked)| *blocked) { + return Err( + "Stopped from another Desktop. Use Start agent to start it again explicitly.".into(), + ); + } + Ok(()) +} + +/// A failed spawn must not unblock config/restore. Commit only after the child +/// has its ordinary receipt and tracked handle, still under the transition lock. +pub(crate) fn finish_resume( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + community: &str, + owner: Option<&str>, + ticket: Option<&ResumeTicket>, +) -> Result<(), String> { + if ticket.is_none() { + return Ok(()); + } + let owner = owner.ok_or("Desktop launch owner unavailable")?; + check_launch(app, key, community, Some(owner), ticket)?; + connection(app, community, owner)? + .execute( + "UPDATE desktop_stop_fence SET blocked=0 WHERE agent=?1", + [&key.pubkey], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Expose outcomes without mistaking a missing record for success. +pub(crate) fn outcome(stopped: Result<(), String>) -> StopOutcome { + if stopped.is_ok() { + StopOutcome::Stopped + } else { + StopOutcome::Failed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Timestamp}; + fn request(keys: &Keys, target: &StopTarget, time: u64) -> Event { + let e = target.sign(keys).unwrap(); + EventBuilder::new(e.kind, e.content) + .tags(e.tags.to_vec()) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() + } + #[test] + fn launch_reads_receiver_fence_in_original_community_not_runtime_alias() { + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = format!("buzz-test-{}", uuid::Uuid::new_v4()); + let state = crate::app_state::build_app_state(); + let keys = state.signing_keys().unwrap(); + let owner = keys.public_key().to_hex(); + let app = tauri::test::mock_builder() + .manage(state) + .build(context) + .unwrap(); + let root = app.path().app_data_dir().unwrap(); + let community = "ws://localhost:3037"; + let agent = Keys::generate().public_key().to_hex(); + let key = ManagedAgentRuntimeKey::new(&agent, community).unwrap(); + assert_eq!(key.relay_url, community); + let numeric_community = "ws://127.0.0.1:3037"; + let numeric_key = ManagedAgentRuntimeKey::new(&agent, numeric_community).unwrap(); + assert_ne!(key, numeric_key); + let scope = super::super::retention::RetentionScope { + db_path: scoped_retention_db_path(&root.join("agents"), community, &owner), + relay_url: community.into(), + owner_keys: keys.clone(), + }; + let mut conn = connection(app.handle(), community, &owner).unwrap(); + let host = crate::commands::desktop_stop::local_id(&mut conn, &scope).unwrap(); + let target = StopTarget { + v: 1, + community: community.into(), + desktop: host, + agent, + }; + let stop = request(&keys, &target, 100); + receive( + &mut conn, + &stop, + &keys, + community, + &target.desktop, + true, + |_| Ok(()), + ) + .unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), None).is_err()); + // The numeric community is a different authority with its own runtime + // identity and fence database. + assert!(check_launch( + app.handle(), + &numeric_key, + numeric_community, + Some(&owner), + None + ) + .is_ok()); + let resume = capture_resume(app.handle(), &key, community, &owner).unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_ok()); + let newer = request(&keys, &target, 101); + receive( + &mut conn, + &newer, + &keys, + community, + &target.desktop, + true, + |_| Ok(()), + ) + .unwrap(); + assert!(check_launch(app.handle(), &key, community, Some(&owner), Some(&resume)).is_err()); + drop(conn); + drop(app); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn launch_fence_requires_explicit_start_and_rejects_delayed_preflight() { + let stopped = ("stop-a".to_owned(), true); + let resumed = ("stop-a".to_owned(), false); + let new_stop = ("stop-b".to_owned(), true); + assert!(allow_launch(None, None).is_ok()); + assert!(allow_launch(Some(&stopped), None).is_err()); + assert!(allow_launch(Some(&resumed), None).is_ok()); + let ticket = ResumeTicket { + previous: Some("stop-a".to_owned()), + }; + assert!(allow_launch(Some(&stopped), Some(&ticket)).is_ok()); + assert!(allow_launch(Some(&new_stop), Some(&ticket)).is_err()); + let before_any_stop = ResumeTicket { previous: None }; + assert!(allow_launch(Some(&stopped), Some(&before_any_stop)).is_err()); + assert!(allow_launch(None, Some(&before_any_stop)).is_ok()); + } + + #[test] + fn saved_result_is_immutable_and_duplicate_after_interruption_is_unknown() { + let mut conn = Connection::open_in_memory().unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let event = request(&keys, &target, 100); + assert!(admit(&mut conn, &event, &target).unwrap()); + // A crash between admission and recording an outcome never reexecutes. + assert!(saved_result(&conn, &event.id.to_hex()).unwrap().is_none()); + assert!(!admit(&mut conn, &event, &target).unwrap()); + save_result(&mut conn, &event.id.to_hex(), "original bytes").unwrap(); + save_result(&mut conn, &event.id.to_hex(), "replacement").unwrap(); + assert_eq!( + saved_result(&conn, &event.id.to_hex()).unwrap().as_deref(), + Some("original bytes") + ); + assert_eq!( + outcome(Err("ordinary Stop failed".into())), + StopOutcome::Failed + ); + assert_eq!(outcome(Ok(())), StopOutcome::Stopped); + } + + #[test] + fn receiver_authenticates_routes_and_returns_exact_saved_result_without_effect() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("receiver.db"); + let mut conn = open_retention_db(&path).unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let event = request(&keys, &target, 100); + let no_effect = |_: &StopTarget| panic!("must not invoke ordinary Stop"); + let foreign = Keys::generate(); + for (signer, community, host, rejected) in [ + ( + &foreign, + target.community.as_str(), + target.desktop.as_str(), + true, + ), + (&keys, "wss://other.example", target.desktop.as_str(), true), + ( + &keys, + target.community.as_str(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + false, + ), + ] { + let result = receive(&mut conn, &event, signer, community, host, true, no_effect); + if rejected { + assert!(result.is_err()); + } else { + assert!(result.unwrap().is_none()); + } + } + let receive_owned = |conn: &mut Connection, event: &Event, owned, stop| { + receive( + conn, + event, + &keys, + &target.community, + &target.desktop, + owned, + stop, + ) + .unwrap() + .unwrap() + }; + let fail: fn(&StopTarget) -> Result<(), String> = |_| Err("ordinary Stop error".into()); + let no_effect: fn(&StopTarget) -> Result<(), String> = no_effect; + // The first effect succeeds. The reopened retry must return its exact + // signed bytes without invoking the callback at all. + let mut effects = 0; + let result = receive( + &mut conn, + &event, + &keys, + &target.community, + &target.desktop, + true, + |actual| { + assert_eq!(actual, &target); + effects += 1; + Ok(()) + }, + ) + .unwrap() + .unwrap(); + assert_eq!(effects, 1); + let assert_outcome = |result: &Event, request: &Event, expected| { + assert_eq!( + StopResult::read(result, &keys, request, &target.community) + .unwrap() + .outcome, + expected + ); + }; + assert_outcome(&result, &event, StopOutcome::Stopped); + drop(conn); + let mut conn = open_retention_db(&path).unwrap(); + let duplicate = receive_owned(&mut conn, &event, true, no_effect); + assert_eq!(result.as_json(), duplicate.as_json()); + let next = request(&keys, &target, 101); + let failed = receive_owned(&mut conn, &next, true, fail); + assert_outcome(&failed, &next, StopOutcome::Failed); + assert_eq!( + failed.as_json(), + receive_owned(&mut conn, &next, true, no_effect).as_json() + ); + let unowned = request(&keys, &target, 102); + let denied = receive_owned(&mut conn, &unowned, false, no_effect); + assert_outcome(&denied, &unowned, StopOutcome::Failed); + assert_eq!( + conn.query_row( + "SELECT event_id FROM desktop_stop_fence WHERE agent=?1", + [&target.agent], + |r| r.get::<_, String>(0) + ) + .unwrap(), + next.id.to_hex() + ); + let interrupted = request(&keys, &target, 103); + assert!(admit(&mut conn, &interrupted, &target).unwrap()); + let unknown = receive_owned(&mut conn, &interrupted, true, no_effect); + assert_outcome(&unknown, &interrupted, StopOutcome::Unknown); + } + + #[test] + fn durable_fence_survives_outcome_eviction_and_accepts_fresh_stop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("stop.db"); + let mut conn = open_retention_db(&path).unwrap(); + let keys = Keys::generate(); + let target = StopTarget { + v: 1, + community: "wss://one.example".into(), + desktop: "a".repeat(32), + agent: Keys::generate().public_key().to_hex(), + }; + let first = request(&keys, &target, 100); + assert!(admit(&mut conn, &first, &target).unwrap()); + assert!(!admit(&mut conn, &first, &target).unwrap()); + for i in 0..HISTORY_LIMIT + 2 { + save_result(&mut conn, &format!("{i}"), "result").unwrap(); + } + drop(conn); + let mut conn = open_retention_db(&path).unwrap(); + assert!(!admit(&mut conn, &first, &target).unwrap()); + assert!(admit(&mut conn, &request(&keys, &target, 101), &target).unwrap()); + assert!(!admit(&mut conn, &request(&keys, &target, 99), &target).unwrap()); + let a = request(&keys, &target, 102); + let b = request(&keys, &target, 102); + let (low, high) = if a.id < b.id { (a, b) } else { (b, a) }; + assert!(admit(&mut conn, &high, &target).unwrap()); + assert!(admit(&mut conn, &low, &target).unwrap()); + assert!(!admit(&mut conn, &high, &target).unwrap()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 5b79ccac27f..aca7cccbb3b 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,8 +1,7 @@ use super::{ bestie_assignment::recover_pending_assignment_cleanup, find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, managed_agents_base_dir, - save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, - ManagedAgentProcess, + save_managed_agents, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; use crate::app_state::AppState; use crate::util; @@ -21,7 +20,11 @@ use tauri::Manager; enum SpawnOutcome { /// Boxed: the spawned process carries its full spawn-config snapshot, so an /// inline variant would make every `Skipped`/`Failed` outcome pay for it. - Spawned(super::ManagedAgentRuntimeKey, Box), + Spawned( + super::ManagedAgentRuntimeKey, + String, + Box, + ), Skipped, Failed(String), } @@ -96,6 +99,53 @@ pub async fn restore_managed_agents_on_launch( app: &tauri::AppHandle, shutdown_started: &AtomicBool, ) -> Result<(), String> { + restore_with( + app, + shutdown_started, + |model, _| async move { + #[cfg(feature = "mesh-llm")] + crate::commands::ensure_relay_mesh_for_record(app, model.as_deref(), false).await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = model; + Ok(()) + }, + |tracked_pids| { + super::sweep_orphaned_agent_processes(app, tracked_pids); + super::sweep_system_agent_processes(&super::current_instance_id(app), tracked_pids); + super::reap_dead_instance_agents(&super::current_instance_id(app), tracked_pids); + super::sweep_untracked_bundle_harnesses(tracked_pids); + }, + |pubkey, data| { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = + crate::commands::reconcile_agent_profile(&state, &app, &pubkey, &data).await + { + eprintln!( + "buzz-desktop: profile reconciliation failed for agent {pubkey}: {error}" + ); + } + }); + }, + ) + .await +} + +// Same restore phases with only external preflight, system sweeps and relay +// publication injectable. Tests run disk/admission/spawn/receipt code unchanged. +pub(crate) async fn restore_with( + app: &tauri::AppHandle, + shutdown_started: &AtomicBool, + preflight: F, + sweep: impl Fn(&[u32]), + reconcile: impl Fn(String, crate::commands::ProfileReconcileData), +) -> Result<(), String> +where + R: tauri::Runtime, + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, +{ if shutdown_started.load(Ordering::SeqCst) { return Ok(()); } @@ -148,28 +198,7 @@ pub async fn restore_managed_agents_on_launch( }), ) .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); - - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); - - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above — catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); + sweep(&tracked_pids); let candidates: Vec = records .iter() @@ -246,38 +275,48 @@ pub async fn restore_managed_agents_on_launch( .ok() .map(|k| k.public_key().to_hex()); - #[cfg(feature = "mesh-llm")] - let agents_to_start = { - // Preflight against the same resolution spawn uses — `resolve_effective_config` - // (definition → global fallback). A linked instance's own `provider`/`model`/ - // `relay_mesh` bytes never contribute. See `start_local_agent_with_preflight` - // in `commands/agents.rs` for the identical rationale on the interactive path. - let personas = load_personas(app).unwrap_or_default(); - let global = super::load_global_agent_config(app).unwrap_or_default(); - let mut mesh_preflight_failures = std::collections::HashSet::new(); - for record in &agents_to_start { - let mesh_model_id = super::effective_config::resolve_effective_relay_mesh_model_id( - record, &personas, &global, + // Capture the actual scoped selection before awaiting, never preflight Default + // and recapture a different selected model at spawn. + let launch_relay = crate::relay::relay_ws_url_with_override(&state); + // Capture the whole batch before any provider suspends. + let captured = agents_to_start + .into_iter() + .map(|record| { + let plan = super::runtime_configurations::capture_for_app( + app, + &record, + owner_hex.as_deref().unwrap_or(""), + &launch_relay, ); - if mesh_model_id.is_none() { - continue; - } - // Auto-start after relaunch: re-resolve a live bootstrap target and - // dial it. Skip (with an actionable error) only when no live target - // serves this model right now. - if let Err(error) = - crate::commands::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false) - .await - { - persist_restore_error(app, &state, &record.pubkey, error)?; - mesh_preflight_failures.insert(record.pubkey.clone()); - } + (record, plan) + }) + .collect::>(); + let mut prepared_agents = Vec::new(); + for (record, preparation) in captured { + let result = async { + let mut plan = preparation?; + super::runtime_configurations::preflight_with( + &mut plan, + owner_hex.as_deref().ok_or("Desktop owner unavailable")?, + &launch_relay, + false, + &preflight, + ) + .await?; + Ok::<_, String>(plan) } - agents_to_start - .into_iter() - .filter(|record| !mesh_preflight_failures.contains(&record.pubkey)) - .collect::>() - }; + .await; + match result { + Ok(plan) => prepared_agents.push((record, plan)), + Err(error) => persist_restore_error(app, &state, &record.pubkey, error)?, + } + } + let agents_to_start = prepared_agents; + if crate::relay::relay_ws_url_with_override(&state) != launch_relay + || state.signing_keys()?.public_key().to_hex() != owner_hex.as_deref().unwrap_or("") + { + return Err("Desktop scope changed during restore preflight".into()); + } if agents_to_start.is_empty() { return Ok(()); } @@ -297,17 +336,13 @@ pub async fn restore_managed_agents_on_launch( // ── Phase B (transition lock held): resolve commands and spawn in parallel ── let spawn_results: Vec = std::thread::scope(|scope| { let owner_hex_ref = owner_hex.as_deref(); + let state = &state; let handles: Vec<_> = agents_to_start .iter() .filter(|_| !shutdown_started.load(Ordering::SeqCst)) - .map(|record| { + .map(|(record, prepared)| { + let relay_url = launch_relay.clone(); let handle = scope.spawn(move || { - let workspace_relay = - crate::relay::relay_ws_url_with_override(&app.state::()); - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, - ); let outcome = match super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) { @@ -330,25 +365,51 @@ pub async fn restore_managed_agents_on_launch( if already_live { SpawnOutcome::Skipped } else { - match super::terminate_untracked_pair_runtime(app, &key) - .and_then(|()| { - // F1: restore spawns lazy, matching - // reconcile and manual start. Eager on - // restore buys nothing — a crashed - // mid-turn session is not resumed by an - // eager child — and silently reintroduces - // N idle brains on every launch. - spawn_agent_child( - app, - record, - &key.relay_url, - true, - owner_hex_ref, - None, - ) - }) { + let result = (|| { + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let current = records + .iter() + .find(|r| r.pubkey == record.pubkey) + .ok_or("Agent removed during restore preflight")?; + super::runtime_configurations::check_selection( + current, + owner_hex_ref, + &relay_url, + prepared.configuration().as_ref(), + )?; + prepared.check_continuation(current)?; + prepared.require_preflight()?; + prepared.revalidate( + current, + &load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + super::remote_stop::check_launch( + app, + &key, + &relay_url, + owner_hex_ref, + None, + )?; + super::terminate_untracked_pair_runtime(app, &key)?; + super::runtime::spawn_agent_child_prepared( + app, + current, + &relay_url, + true, + owner_hex_ref, + None, + None, + Some(prepared), + ) + })(); + match result { Ok(process) => { - SpawnOutcome::Spawned(key, Box::new(process)) + SpawnOutcome::Spawned(key, relay_url, Box::new(process)) } Err(error) => SpawnOutcome::Failed(error), } @@ -387,17 +448,18 @@ pub async fn restore_managed_agents_on_launch( // Skipped means a concurrent reconcile already owns a live child for // this pair; leave its runtime and record state untouched. SpawnOutcome::Skipped => continue, - SpawnOutcome::Spawned(key, mut process) => { + SpawnOutcome::Spawned(key, relay_url, mut process) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { continue; }; let now = util::now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: super::current_instance_id(app), - started_at: now.clone(), - }; + let mut receipt = super::ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + super::current_instance_id(app), + now.clone(), + ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = super::terminate_process(process.child.id()); let _ = process.child.wait(); @@ -415,11 +477,11 @@ pub async fn restore_managed_agents_on_launch( key.clone(), super::ManagedAgentPairRuntime::starting(*process), ); - // Carry the spawn key's relay into profile reconciliation so + // Carry the original launch community into profile reconciliation so // the background task queries/publishes on the relay this // spawn was actually keyed to — not whatever workspace is // active when the task eventually executes. - successfully_spawned.push((pubkey, key.relay_url.clone())); + successfully_spawned.push((pubkey, relay_url)); } SpawnOutcome::Failed(error) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { @@ -470,7 +532,7 @@ pub async fn restore_managed_agents_on_launch( }) .collect(); - save_managed_agents(app, &records)?; + super::storage::save_runtime_metadata_batch(app, &records)?; drop(runtimes); drop(_store_guard); drop(restore_transition); @@ -479,16 +541,7 @@ pub async fn restore_managed_agents_on_launch( // Spawn background tasks to ensure each restored agent's kind:0 profile is // published on the relay. Same pattern as the UI start path. for (pubkey, data) in reconcile_items { - let reconcile_app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = reconcile_app.state::(); - if let Err(e) = - crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) - .await - { - eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); - } - }); + reconcile(pubkey, data); } Ok(()) @@ -545,6 +598,23 @@ pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, work } } +fn persist_restore_error( + app: &tauri::AppHandle, + state: &AppState, + pubkey: &str, + error: String, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.updated_at = util::now_iso(); + record.last_error = Some(error); + super::storage::save_runtime_metadata(app, record) +} + #[cfg(test)] mod profile_reconcile_tests { use super::profile_reconcile_completed; @@ -560,21 +630,3 @@ mod profile_reconcile_tests { )); } } - -#[cfg(feature = "mesh-llm")] -fn persist_restore_error( - app: &tauri::AppHandle, - state: &AppState, - pubkey: &str, - error: String, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - record.updated_at = util::now_iso(); - record.last_error = Some(error); - save_managed_agents(app, &records) -} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8d586b32af..ed5c0f0b4c0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -31,7 +31,7 @@ mod setup_payload; use setup_payload::apply_setup_payload_env; mod stop; -pub(crate) use stop::managed_agent_runtime_keys; +pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair}; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; @@ -41,11 +41,13 @@ mod process; #[cfg(test)] use process::{ buzz_marker_entry, name_matches_interpreter, name_matches_known_binary, - terminate_runtime_receipt_with, valid_agent_runtime_receipt_with, + select_pair_runtime_receipt_with, terminate_runtime_receipt_with, + valid_agent_runtime_receipt_with, }; pub(crate) use process::{ current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running, terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt, + with_pair_runtime_receipt_authority, }; mod orphan_sweep; @@ -108,8 +110,8 @@ fn persona_drift_state( /// pin is ignored — see `effective_agent_relay_url`). Returns `None` for /// records that cannot form a valid pair key yet (e.g. key-less agents that /// mint keys on first start). -pub(crate) fn workspace_pair_key( - app: &AppHandle, +pub(crate) fn workspace_pair_key( + app: &AppHandle, record: &ManagedAgentRecord, ) -> Option { let state = app.state::(); @@ -133,8 +135,8 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } -pub fn build_managed_agent_summary( - app: &AppHandle, +pub fn build_managed_agent_summary( + app: &AppHandle, record: &ManagedAgentRecord, runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], @@ -330,7 +332,25 @@ pub fn build_managed_agent_summary( last_error: record.last_error.clone(), last_error_code: record.last_error_code, start_on_app_launch: record.start_on_app_launch, - auto_restart_on_config_change: record.auto_restart_on_config_change, + auto_restart_on_config_change: record.auto_restart_on_config_change + && pair_runtime + .is_none_or(|runtime| runtime.spawn_config.runtime_configuration.is_none()) + && app + .state::() + .signing_keys() + .ok() + .is_some_and(|keys| { + record + .runtime_configurations + .get( + &keys.public_key().to_hex(), + &crate::relay::relay_ws_url_with_override( + app.state::().inner(), + ), + ) + .entries + .is_empty() + }), log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), @@ -402,11 +422,12 @@ pub(crate) fn configure_runtime_cli( #[must_use] pub(crate) struct EffortApplied(()); -/// Apply effort env to an agent spawn command. Called by `spawn_agent_child` -/// (production) and `effort_cmd_tests` (test seam). Inner-seam: removing -/// `apply_spawn_effort_env` below turns the production-sequence tests RED. -/// Outer-seam: the returned token is consumed by `spawn_with_effort_proof`; -/// deleting this call leaves `effort` undefined at the spawn site. +/// Apply effort env to an agent spawn command. Called by +/// `spawn_agent_child_prepared` (production) and `effort_cmd_tests` (test +/// seam). Inner-seam: removing `apply_spawn_effort_env` below turns the +/// production-sequence tests RED. Outer-seam: the returned token is consumed +/// by `spawn_with_effort_proof`; deleting this call leaves `effort` undefined +/// at the spawn site. pub(crate) fn apply_effort_to_spawn_command( cmd: &mut std::process::Command, record: &crate::managed_agents::types::ManagedAgentRecord, @@ -423,8 +444,9 @@ pub(crate) fn apply_effort_to_spawn_command( } /// Spawn the agent command, consuming the `EffortApplied` proof token. -/// Deleting `apply_effort_to_spawn_command` from `spawn_agent_child` leaves -/// `effort` undefined here — a compile error CI catches before any test runs. +/// Deleting `apply_effort_to_spawn_command` from `spawn_agent_child_prepared` +/// leaves `effort` undefined here — a compile error CI catches before any +/// test runs. pub(crate) fn spawn_with_effort_proof( cmd: &mut std::process::Command, _effort: EffortApplied, @@ -444,14 +466,23 @@ pub(crate) fn spawn_with_effort_proof( /// publishes the triggering message before this spawn and passes its send /// timestamp here so the harness's first REQ replays past that message no /// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min. -pub fn spawn_agent_child( - app: &AppHandle, +/// +/// `prepared`: the captured, preflighted launch plan. Production callers pass +/// a `PreparedLaunch`; tests may pass `None` to exercise the refusal paths +/// that reject an un-preflighted spawn. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_agent_child_prepared( + app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, owner_hex: Option<&str>, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, + prepared: Option<&super::runtime_configurations::PreparedLaunch>, ) -> Result { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + super::remote_stop::check_launch(app, &key, relay_url, owner_hex, resume)?; if let Some(error) = spawn_key_refusal(record) { return Err(error); } @@ -462,7 +493,6 @@ pub fn spawn_agent_child( // command, so we recompute them from the effective value rather than the // frozen record snapshot. Mirrors the model resolution below. let personas = super::load_personas(app).unwrap_or_default(); - let teams = super::load_teams(app).unwrap_or_default(); // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) // and for the env-var merge at spawn time. let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); @@ -478,29 +508,37 @@ pub fn spawn_agent_child( // inherits it — no caller can bypass this by reaching `spawn_agent_child` // directly. Checked before any side effect (log marker, log file, process // spawn) so a refused spawn leaves no trace. - let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( - record, &personas, &global, - ) - .require_resolved()?; - - // Single typed resolver: validates runtime id (dangling harness → Err), resolves - // command, args (instance wins over definition default), and the full env layer stack. - // This is the sole path for harness-definition lookup — spawn, snapshot, - // summary, and model probes all consume this descriptor rather than - // assembling values inline. - // Like the orphan refusal above, this runs before any side effect so a refused - // spawn leaves no trace. - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) - .map_err(|e| { - format!( - "cannot spawn agent {}: {}", - record.pubkey, - crate::managed_agents::user_facing_harness_error(&e) - ) - })?; + let plan = prepared.ok_or("Captured preflighted runtime launch required")?; + plan.require_preflight()?; + plan.check_scope(owner_hex, relay_url)?; + plan.revalidate(record, &personas, &global)?; + // Keep projected settings immutable, but deliver only the current credential + // that just passed revalidation, never the captured plan's copy. + let mut launch_record = plan.record.clone(); + launch_record.private_key_nsec = if plan.configuration().is_some() { + let current = super::storage::load_managed_agents_for_launch(app)? + .into_iter() + .find(|saved| saved.pubkey == record.pubkey) + .ok_or("Agent removed before launch")?; + plan.revalidate(¤t, &personas, &global)?; + current.private_key_nsec + } else { + record.private_key_nsec.clone() + }; + let record = &launch_record; + let effective_cfg = plan.effective.clone(); + let descriptor = &plan.descriptor; let effective_command = &descriptor.command; let agent_args = &descriptor.args; + let (team_instructions, acp_session_policy) = plan + .app_inputs + .as_ref() + .ok_or("Launch plan has no app inputs")?; + let required_mcp = if super::runtime_configurations::selected(record)?.is_some() { + super::runtime_configurations::required_mcp_command(effective_command)? + } else { + None + }; let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( @@ -542,7 +580,8 @@ pub fn spawn_agent_child( // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. - let effective_relay_url = runtime_key.relay_url.clone(); + // Process identity normalization must not select a different relay tenant. + let effective_relay_url = relay_url.to_owned(); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -601,7 +640,7 @@ pub fn spawn_agent_child( // ── Readiness check: set setup-payload if agent is not ready ───────────── // `spawned_setup_mode` is stamped on `ManagedAgentProcess` below. let spawned_setup_mode = - apply_setup_payload_env(&mut command, record, &descriptor, runtime_meta); + apply_setup_payload_env(&mut command, record, descriptor, runtime_meta); // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). @@ -623,8 +662,7 @@ pub fn spawn_agent_child( } } } - let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams); - if let Some(instructions) = &team_instructions { + if let Some(instructions) = team_instructions { command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); } else { command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); @@ -755,8 +793,9 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } - // Resolve once and stamp the same value onto the snapshot below. - let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); + // Session partitioning is launch input; operational admission/logging policy + // remains live. Default and named both stamp exactly the captured policy. + super::session_policy::apply_acp_session_policy_env(&mut command, *acp_session_policy); crate::build_identity::apply_demo_config_home(&mut command)?; // Publish-first replay floor: written AFTER the `descriptor.env` loop, the @@ -800,14 +839,14 @@ pub fn spawn_agent_child( let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( super::spawn_snapshot::SpawnConfigInputs { record, - descriptor: &descriptor, + descriptor, relay_url: &effective_relay_url, team_instructions: team_instructions.as_deref(), system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), enforced_owner_only: super::owner_only_access_build(), - session_policy: acp_session_policy, + session_policy: *acp_session_policy, }, ); @@ -827,6 +866,28 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } + // Applied last so inherited environment cannot weaken explicit model selection. + let configuration = super::runtime_configurations::selected(record)?; + let required_model = configuration + .map(|_| { + acp_model + .as_deref() + .ok_or("Named launch has no resolved model") + }) + .transpose()?; + super::runtime_configurations::apply_required_model_env(&mut command, required_model)?; + if let Some(model) = required_model { + if runtime_meta.is_none_or(|runtime| runtime.id != "claude") { + command.env("BUZZ_ACP_MODEL", model); + } + } + if let Some(mcp) = required_mcp { + // A saved environment cannot replace or disable a declared named tool. + command.env("BUZZ_ACP_MCP_COMMAND", mcp); + } + if let Some(workspace) = configuration.and_then(|config| config.workspace.as_ref()) { + command.current_dir(workspace); + } let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", @@ -868,21 +929,57 @@ pub fn spawn_agent_child( }) } -/// Spawn (or adopt) the runtime pair for `record` on the caller's bound -/// workspace relay. `workspace_relay` can only be produced by -/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the -/// exact workspace-relay read the caller's scope assertion passed on; it never -/// re-reads the mutable override (see `relay::scope`). The key comes from -/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise. -pub fn start_managed_agent_process( - app: &AppHandle, +/// Test-only seam over [`start_managed_agent_process_prepared`] with no +/// captured launch: the runtime-authority regressions in +/// `runtime/authority_tests.rs` use it to prove a spawn without a preflighted +/// plan is refused before any side effect. Production callers resolve a +/// `PreparedLaunch` and go through `start_managed_agent_process_prepared` +/// directly. +#[cfg(test)] +pub fn start_managed_agent_process( + app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, +) -> Result<(), String> { + start_managed_agent_process_prepared( + app, + record, + runtimes, + owner_hex, + workspace_relay, + replay_floor_unix, + resume, + None, + ) +} + +/// Ordinary pair registration using a previously resolved immutable launch. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_managed_agent_process_prepared( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, + owner_hex: Option<&str>, + workspace_relay: &crate::relay::ScopedWorkspaceRelay, + replay_floor_unix: Option, + resume: Option<&super::remote_stop::ResumeTicket>, + prepared: Option<&super::runtime_configurations::PreparedLaunch>, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; + super::with_pair_runtime_receipt_authority(app, &key, || Ok(()))?; + let plan = prepared.ok_or("Captured preflighted runtime launch required")?; + plan.require_preflight()?; + plan.check_scope(owner_hex, workspace_relay.as_str())?; + plan.revalidate( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + )?; + super::remote_stop::check_launch(app, &key, workspace_relay.as_str(), owner_hex, resume)?; if let Some(runtime) = runtimes.get_mut(&key) { if runtime .child @@ -890,6 +987,10 @@ pub fn start_managed_agent_process( .map_err(|error| format!("failed to inspect running process: {error}"))? .is_none() { + let requested = plan.configuration(); + if runtime.spawn_config.runtime_configuration != requested { + return Err("A different configuration is running; Stop before Start".into()); + } return Ok(()); } @@ -900,21 +1001,28 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; - let mut process = spawn_agent_child( + // A prior-session receipt is the only untracked process this pair may + // replace. Selection enforces host-preserving authority provenance and + // uses the ordinary process-tree termination contract. + terminate_untracked_pair_runtime(app, &key)?; + let mut process = spawn_agent_child_prepared( app, record, - &key.relay_url, + workspace_relay.as_str(), false, owner_hex, replay_floor_unix, + resume, + prepared, )?; let now = now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: current_instance_id(app), - started_at: now.clone(), - }; + let mut receipt = super::ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + current_instance_id(app), + now.clone(), + ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { let _ = terminate_process(process.child.id()); let _ = process.child.wait(); @@ -928,12 +1036,13 @@ pub fn start_managed_agent_process( record.last_error = None; record.last_error_code = None; - runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); + runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(app, &key, workspace_relay.as_str(), owner_hex, resume)?; Ok(()) } #[cfg(test)] -mod test_fixtures; +pub(super) mod test_fixtures; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs new file mode 100644 index 00000000000..59f270ff203 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/authority_tests.rs @@ -0,0 +1,365 @@ +//! Runtime authority migration and production Start regressions. + +use super::super as runtime; +use super::receipt_fixture; + +#[test] +fn legacy_receipt_validation_uses_legacy_rendering_for_global_ownership() { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new( + "aa".repeat(32), + "wss://relay.example?mode=one", + ) + .unwrap(), + ); + receipt.authority_version = 0; + // url::Url serialization retained the root slash before a query in V0, + // while the scoped runtime renderer intentionally removes that root slash. + receipt.key.relay_url = "wss://relay.example/?mode=one".into(); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + assert!(runtime::valid_agent_runtime_receipt_with( + &path, + &receipt, + "test-instance", + |_| true, + |_, _| true, + )); +} + +#[test] +fn legacy_normalizer_loss_boundaries_are_pinned_to_the_real_url_renderer() { + let normalize = buzz_core_pkg::relay::normalize_relay_url; + assert_eq!( + normalize("wss://relay.example/room/").unwrap(), + "wss://relay.example/room" + ); + assert_eq!( + normalize("wss://relay.example/room?tail=/").unwrap(), + "wss://relay.example/room?tail=" + ); + assert_eq!( + normalize("wss://relay.example/?mode=one").unwrap(), + "wss://relay.example/?mode=one" + ); + assert_eq!( + normalize("wss://relay.example/?").unwrap(), + "wss://relay.example/?" + ); +} + +#[test] +fn replacement_removes_receipt_only_after_confirmed_exit() { + use std::cell::{Cell, RefCell}; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let path = std::path::Path::new("pair.json"); + let terminated = Cell::new(None); + let polls = Cell::new(0); + let removed = RefCell::new(None); + + runtime::terminate_runtime_receipt_with( + path, + &receipt, + |pid| { + terminated.set(Some(pid)); + Ok(()) + }, + |_| { + let poll = polls.get() + 1; + polls.set(poll); + poll < 2 + }, + |path| *removed.borrow_mut() = Some(path.to_path_buf()), + ) + .unwrap(); + + assert_eq!(terminated.get(), Some(receipt.pid)); + assert_eq!(polls.get(), 2); + assert_eq!(removed.into_inner().as_deref(), Some(path)); +} + +#[test] +fn replacement_failure_keeps_receipt() { + use std::cell::Cell; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let removed = Cell::new(false); + let error = runtime::terminate_runtime_receipt_with( + std::path::Path::new("pair.json"), + &receipt, + |_| Err("signal failed".into()), + |_| false, + |_| removed.set(true), + ) + .unwrap_err(); + + assert_eq!(error, "signal failed"); + assert!(!removed.get()); +} + +#[cfg(unix)] +#[test] +fn production_start_refuses_live_unversioned_receipt_before_spawn() { + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let relay = "wss://relay.example"; + let pubkey = "aa".repeat(32); + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = runtime::current_instance_id(app.handle()); + let mut child = runtime::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap(); + assert!(runtime::process_has_buzz_marker(child.id(), &instance_id)); + + let key = crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, relay).unwrap(); + let receipt = crate::managed_agents::ManagedAgentRuntimeReceipt { + runtime_configuration: None, + authority_version: 0, + key: key.clone(), + pid: child.id(), + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + crate::managed_agents::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + let mut record = runtime::test_fixtures::fixture( + crate::managed_agents::RespondTo::OwnerOnly, + Vec::new(), + None, + ); + record.pubkey = pubkey; + record.acp_command = "a-command-that-must-not-be-resolved".into(); + let bound = crate::relay::bind_expected_relay_scope(None, relay.into()).unwrap(); + let mut runtimes = std::collections::HashMap::new(); + + let error = runtime::start_managed_agent_process( + app.handle(), + &mut record, + &mut runtimes, + None, + &bound, + None, + None, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert!(!error.contains("crash")); + assert!(runtimes.is_empty()); + assert!(child.child_mut().try_wait().unwrap().is_none()); + assert!( + crate::managed_agents::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt) + ); + crate::managed_agents::remove_agent_runtime_receipt(app.handle(), &key); +} + +#[test] +fn receipt_selection_refuses_ambiguous_unversioned_loopback_authority() { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000") + .unwrap(), + ); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + for requested_relay in [ + "ws://127.0.0.1:3000", + "ws://localhost:3000", + "ws://127.0.0.2:3000", + "ws://[::1]:3000", + ] { + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), requested_relay) + .unwrap(); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path.clone(), receipt.clone())], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!( + error.contains("cannot prove the requested community authority"), + "legacy receipt must not prove {requested_relay}" + ); + } +} + +#[test] +fn receipt_selection_keeps_versioned_loopback_authorities_disjoint() { + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://127.0.0.1:3000") + .unwrap(), + ); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "ws://localhost:3000") + .unwrap(); + + let selected = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap(); + assert!(selected.is_none()); +} + +#[test] +fn receipt_selection_refuses_unversioned_non_loopback_lossy_urls() { + let pubkey = "aa".repeat(32); + for (stored_relay, requested_relay) in [ + ("wss://relay.example/room", "wss://relay.example/room"), + ("wss://relay.example/room", "wss://relay.example/room/"), + ( + "wss://relay.example/?mode=one", + "wss://relay.example?mode=one", + ), + ( + "wss://relay.example/room?tail=", + "wss://relay.example/room?tail=/", + ), + ] { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new( + pubkey.clone(), + "wss://relay.example", + ) + .unwrap(), + ); + receipt.authority_version = 0; + receipt.key.relay_url = stored_relay.into(); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new(pubkey.clone(), requested_relay) + .unwrap(); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!( + error.contains("cannot prove the requested community authority"), + "legacy {stored_relay} must not prove {requested_relay}" + ); + } +} + +#[test] +fn legacy_renderer_collapses_repeated_root_slashes_but_modern_keys_do_not() { + let parsed = url::Url::parse("wss://relay.example//").unwrap(); + assert_eq!(parsed.path(), "//"); + assert_eq!( + buzz_core_pkg::relay::normalize_relay_url("wss://relay.example//").unwrap(), + "wss://relay.example" + ); + let root = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let repeated = crate::managed_agents::ManagedAgentRuntimeKey::new( + "aa".repeat(32), + "wss://relay.example//", + ) + .unwrap(); + assert_ne!(root, repeated); +} + +#[test] +fn receipt_selection_refuses_unversioned_root_authority() { + let key = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let mut receipt = receipt_fixture(key.clone()); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &key, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +#[test] +fn repeated_root_request_is_refused_for_colliding_unversioned_receipt() { + let pubkey = "aa".repeat(32); + let stored = + crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example").unwrap(); + let requested = + crate::managed_agents::ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example//") + .unwrap(); + let mut receipt = receipt_fixture(stored); + receipt.authority_version = 0; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &requested, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +#[test] +fn receipt_selection_refuses_unknown_future_authority_version() { + let key = + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(); + let mut receipt = receipt_fixture(key.clone()); + receipt.authority_version = crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION + 1; + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + + let error = runtime::select_pair_runtime_receipt_with( + vec![(path, receipt)], + &key, + "test-instance", + |_| true, + |_, _| true, + ) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); +} + +// ── workspace pair-key resolution (summary/stop scoping) ──────────────── diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs index 26aa26f0747..ac3393394ee 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/process.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -395,7 +395,9 @@ pub(crate) fn valid_agent_runtime_receipt( /// Injectable version of `valid_agent_runtime_receipt` for testing. /// `is_running(pid)` and `has_marker(pid, instance_id)` can be substituted by -/// test doubles without spawning real processes. +/// test doubles without spawning real processes. Validity here proves only +/// instance ownership for global cleanup; pair actions must additionally use +/// `select_pair_runtime_receipt_with` to establish authority provenance. pub(crate) fn valid_agent_runtime_receipt_with( path: &std::path::Path, receipt: &super::super::ManagedAgentRuntimeReceipt, @@ -403,12 +405,21 @@ pub(crate) fn valid_agent_runtime_receipt_with( is_running: impl Fn(u32) -> bool, has_marker: impl Fn(u32, &str) -> bool, ) -> bool { - let Ok(canonical) = + let key_rendering_is_valid = if receipt.authority_version == 0 { + receipt.key.pubkey.len() == 64 + && receipt + .key + .pubkey + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + && receipt.key.pubkey == receipt.key.pubkey.to_ascii_lowercase() + && buzz_core_pkg::relay::normalize_relay_url(&receipt.key.relay_url) + .is_ok_and(|legacy| legacy == receipt.key.relay_url) + } else { ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) - else { - return false; + .is_ok_and(|canonical| canonical == receipt.key) }; - canonical == receipt.key + key_rendering_is_valid && path.file_name().and_then(|name| name.to_str()) == Some(&format!("{}.json", receipt.key.runtime_id())) && receipt.desktop_instance_id == instance_id @@ -441,20 +452,93 @@ pub(super) fn terminate_runtime_receipt_with( )) } +fn receipt_has_proven_pair_authority(receipt: &super::super::ManagedAgentRuntimeReceipt) -> bool { + receipt.authority_version == super::super::RUNTIME_AUTHORITY_RECEIPT_VERSION +} + +fn unversioned_receipt_may_ambiguously_match( + receipt: &super::super::ManagedAgentRuntimeReceipt, + key: &ManagedAgentRuntimeKey, +) -> bool { + if receipt.authority_version != 0 || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey) { + return false; + } + buzz_core_pkg::relay::normalize_relay_url(&key.relay_url) + .is_ok_and(|legacy_relay| legacy_relay == receipt.key.relay_url) +} + +/// Select a receipt only when it proves the requested pair authority. +/// +/// Unversioned receipts used a lossy normalizer that folded loopback hosts and +/// stripped every terminal slash. They can establish instance ownership for +/// global cleanup but cannot prove which new runtime key a pair-scoped action +/// owns, including an apparently exact root URL: `wss://h//` and `wss://h` +/// share the V0 rendering but are distinct modern keys. +pub(crate) fn select_pair_runtime_receipt_with( + entries: Vec<(std::path::PathBuf, super::super::ManagedAgentRuntimeReceipt)>, + key: &ManagedAgentRuntimeKey, + instance_id: &str, + is_running: impl Fn(u32) -> bool, + has_marker: impl Fn(u32, &str) -> bool, +) -> Result, String> { + let mut selected = None; + for (path, receipt) in entries { + if !valid_agent_runtime_receipt_with(&path, &receipt, instance_id, &is_running, &has_marker) + || !receipt.key.pubkey.eq_ignore_ascii_case(&key.pubkey) + { + continue; + } + + let exact = receipt.key == *key; + if (exact && !receipt_has_proven_pair_authority(&receipt)) + || unversioned_receipt_may_ambiguously_match(&receipt, key) + { + return Err( + "Runtime receipt cannot prove the requested community authority; quit Buzz Desktop normally, then reopen it before retrying this Start or Stop" + .into(), + ); + } + if exact { + selected = Some((path, receipt)); + } + } + Ok(selected) +} + +/// Run a pair action only after every live receipt that could name the pair +/// has proven authority. The check itself performs no process termination. +pub(crate) fn with_pair_runtime_receipt_authority( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, + effect: impl FnOnce() -> Result, +) -> Result { + let instance_id = current_instance_id(app); + select_pair_runtime_receipt_with( + super::super::read_all_agent_runtime_receipts(app), + key, + &instance_id, + process_is_running, + process_has_buzz_marker, + )?; + effect() +} + /// Replace a valid prior-session process before registering a new child for /// the same pair. The caller must hold the runtime transition lock so receipt /// inspection, termination, spawn, and registration cannot race shutdown or /// another start. -pub(crate) fn terminate_untracked_pair_runtime( - app: &AppHandle, +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result<(), String> { let instance_id = current_instance_id(app); - let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app) - .into_iter() - .find(|(path, receipt)| { - receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) - }) + let Some((path, receipt)) = select_pair_runtime_receipt_with( + super::super::read_all_agent_runtime_receipts(app), + key, + &instance_id, + process_is_running, + process_has_buzz_marker, + )? else { return Ok(()); }; diff --git a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs index fe302ffc67e..1869a1f7642 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/spawn_key.rs @@ -81,4 +81,21 @@ mod tests { let key = bound_runtime_key(&record, &bound).expect("keyable record and relay"); assert_eq!(key.relay_url, "wss://tenant-a.example"); } + + #[test] + fn production_spawn_key_preserves_loopback_community_authority() { + let record = record(&"cc".repeat(32), ""); + let localhost = + crate::relay::bind_expected_relay_scope(None, "ws://localhost:3000".to_string()) + .unwrap(); + let numeric = + crate::relay::bind_expected_relay_scope(None, "ws://127.0.0.1:3000".to_string()) + .unwrap(); + + let localhost_key = bound_runtime_key(&record, &localhost).unwrap(); + let numeric_key = bound_runtime_key(&record, &numeric).unwrap(); + assert_eq!(localhost_key.relay_url, "ws://localhost:3000"); + assert_eq!(numeric_key.relay_url, "ws://127.0.0.1:3000"); + assert_ne!(localhost_key, numeric_key); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 7b8ded7926d..3b2962b63a8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls( /// runtime is reinserted so the pair stays visible and stoppable instead of /// becoming an invisible orphan. Touches no other pair for the agent and /// does no record-level stop bookkeeping — callers own that. -fn stop_managed_agent_pair( +pub(crate) fn stop_managed_agent_pair( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, @@ -120,31 +120,33 @@ fn stop_legacy_scalar_pid( /// pairs in other communities. Clears the matching agent session cache /// (pair-scoped when a pair key resolves). When no pair is tracked for this /// workspace, only legacy scalar-PID cleanup runs. -pub fn stop_managed_agent_workspace_pair( - app: &AppHandle, +pub fn stop_managed_agent_workspace_pair( + app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, ) -> Result<(), String> { use tauri::Manager; let state = app.state::(); match super::workspace_pair_key(app, record) { - Some(pair_key) if runtimes.contains_key(&pair_key) => { - stop_managed_agent_pair(app, record, runtimes, &pair_key)?; - state.clear_agent_session_cache(&pair_key); - super::super::remove_agent_pid_file(app, &record.pubkey); - let now = now_iso(); - record.runtime_pid = None; - record.updated_at = now.clone(); - record.last_stopped_at = Some(now); - record.last_error = None; - record.last_error_code = None; - } - Some(pair_key) => { - // No tracked pair here — a pubkey-wide cache clear would disturb - // live pairs in other communities, so stay pair-scoped. - stop_legacy_scalar_pid(app, record)?; + Some(pair_key) => super::with_pair_runtime_receipt_authority(app, &pair_key, || { + if runtimes.contains_key(&pair_key) { + stop_managed_agent_pair(app, record, runtimes, &pair_key)?; + super::super::remove_agent_pid_file(app, &record.pubkey); + let now = now_iso(); + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_stopped_at = Some(now); + record.last_error = None; + record.last_error_code = None; + } else { + // No tracked pair here — a pubkey-wide cache clear would + // disturb live pairs in other communities, so stay scoped. + super::terminate_untracked_pair_runtime(app, &pair_key)?; + stop_legacy_scalar_pid(app, record)?; + } state.clear_agent_session_cache(&pair_key); - } + Ok(()) + })?, None => { stop_legacy_scalar_pid(app, record)?; state.clear_agent_session_caches(&record.pubkey); @@ -250,4 +252,152 @@ mod tests { selected.sort_by(|left, right| left.relay_url.cmp(&right.relay_url)); assert_eq!(selected, vec![first, second]); } + + #[cfg(unix)] + fn local_stop_refuses_ambiguous_receipt_before_side_effects(tracked: bool) { + use tauri::Manager as _; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let requested_relay = "wss://relay.example/room/"; + let stored_relay = "wss://relay.example/room"; + let pubkey = "aa".repeat(32); + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(requested_relay.into()); + let app = tauri::test::mock_builder() + .manage(state) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = super::super::current_instance_id(app.handle()); + + let mut child = + Some(super::super::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap()); + let pid = child.as_ref().unwrap().id(); + let _process_guard = super::super::test_fixtures::MarkedProcessGuard::new(pid); + assert!(super::super::process_has_buzz_marker(pid, &instance_id)); + + let stored_key = ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); + let requested_key = ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); + let receipt = super::super::super::ManagedAgentRuntimeReceipt { + runtime_configuration: None, + authority_version: 0, + key: stored_key.clone(), + pid, + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + super::super::super::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": "test", + "private_key_nsec": "", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "before" + })) + .unwrap(); + let mut runtimes = HashMap::new(); + if tracked { + let process = crate::managed_agents::ManagedAgentProcess { + child: child.take().unwrap().into_child(), + log_path: Default::default(), + spawn_config: + crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record, + &[], + &[], + requested_relay, + &Default::default(), + false, + crate::managed_agents::AcpSessionPolicy::Channel, + ), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".into(), + }; + runtimes.insert( + requested_key.clone(), + ManagedAgentPairRuntime::starting(process), + ); + } + + let cache: crate::managed_agents::config_bridge::SessionConfigCache = + serde_json::from_value(serde_json::json!({ + "configOptions": [], + "availableModes": [], + "availableModels": [], + "currentModel": null, + "modelOverridden": false, + "gooseNativeConfig": null, + "capturedAt": "now" + })) + .unwrap(); + app.state::() + .put_session_cache(requested_key.clone(), cache); + + let error = stop_managed_agent_workspace_pair(app.handle(), &mut record, &mut runtimes) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert_eq!(record.updated_at, "before"); + assert!(record.last_stopped_at.is_none()); + assert!(app + .state::() + .get_session_cache(&requested_key) + .is_some()); + assert!( + super::super::super::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt) + ); + + if tracked { + let runtime = runtimes.get_mut(&requested_key).unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + let mut runtime = runtimes.remove(&requested_key).unwrap(); + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } else { + let child = child.as_mut().unwrap(); + assert!(child.child_mut().try_wait().unwrap().is_none()); + } + super::super::super::remove_agent_runtime_receipt(app.handle(), &stored_key); + } + + #[cfg(unix)] + #[test] + fn tracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() { + local_stop_refuses_ambiguous_receipt_before_side_effects(true); + } + + #[cfg(unix)] + #[test] + fn untracked_local_stop_has_no_side_effect_before_ambiguous_receipt_refusal() { + local_stop_refuses_ambiguous_receipt_before_side_effects(false); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 05e11fc4cdf..dba54e99061 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -1,5 +1,146 @@ use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; +#[cfg(unix)] +const MARKED_CHILD_FIXTURE_ENV: &str = "BUZZ_TEST_MARKED_CHILD_FIXTURE"; +#[cfg(unix)] +const MARKED_CHILD_READY_ENV: &str = "BUZZ_TEST_MARKED_CHILD_READY"; + +/// Test-executable child whose environment is stable and directly observable +/// through the production process-marker reader. +#[cfg(unix)] +pub(in crate::managed_agents) struct MarkedTestChild { + child: Option, + _ready_dir: tempfile::TempDir, +} + +#[cfg(unix)] +impl MarkedTestChild { + pub(in crate::managed_agents) fn spawn(instance_id: &str) -> Result { + use std::os::unix::process::CommandExt as _; + use std::process::{Command, Stdio}; + + let ready_dir = tempfile::tempdir().map_err(|error| error.to_string())?; + let ready_path = ready_dir.path().join("ready"); + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut child = Command::new(executable) + .args([ + "--exact", + "managed_agents::runtime::test_fixtures::marked_child_process_fixture", + "--nocapture", + ]) + .env_clear() + .env(MARKED_CHILD_FIXTURE_ENV, "1") + .env(MARKED_CHILD_READY_ENV, &ready_path) + .env("BUZZ_MANAGED_AGENT", instance_id) + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| error.to_string())?; + + for _ in 0..100 { + if ready_path.is_file() { + return Ok(Self { + child: Some(child), + _ready_dir: ready_dir, + }); + } + match child.try_wait() { + Ok(Some(status)) => { + return Err(format!( + "marked child fixture exited before readiness: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + return Err(format!("failed to inspect marked child fixture: {error}")); + } + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + Err("marked child fixture did not become ready".into()) + } + + pub(in crate::managed_agents) fn id(&self) -> u32 { + self.child.as_ref().expect("child is present").id() + } + + pub(in crate::managed_agents) fn child_mut(&mut self) -> &mut std::process::Child { + self.child.as_mut().expect("child is present") + } + + pub(in crate::managed_agents) fn into_child(mut self) -> std::process::Child { + self.child.take().expect("child is present") + } +} + +#[cfg(unix)] +impl Drop for MarkedTestChild { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = super::terminate_process(child.id()); + let _ = child.wait(); + } + } +} + +/// Backstop for children whose owned `Child` handle is moved into production +/// runtime state. A failed assertion still terminates the complete process +/// group; successful tests explicitly wait through the owned handle. +#[cfg(unix)] +pub(in crate::managed_agents) struct MarkedProcessGuard(u32); + +#[cfg(unix)] +impl MarkedProcessGuard { + pub(in crate::managed_agents) fn new(pid: u32) -> Self { + Self(pid) + } +} + +#[cfg(unix)] +impl Drop for MarkedProcessGuard { + fn drop(&mut self) { + let _ = super::terminate_process(self.0); + } +} + +#[cfg(unix)] +#[test] +fn marked_child_process_fixture() { + if std::env::var_os(MARKED_CHILD_FIXTURE_ENV).is_none() { + return; + } + let ready_path = std::env::var_os(MARKED_CHILD_READY_ENV) + .expect("marked child fixture requires a readiness path"); + // Optional lifecycle-fixture mode. Install TERM handling before readiness; + // mutate only the explicitly supplied temporary store, then exit normally. + // This runs in the dedicated child executable, never the parent test runner. + if let Some(source) = std::env::var_os("BUZZ_TEST_MARKED_CHILD_REVOKED_STORE") { + let destination = std::env::var_os("BUZZ_TEST_MARKED_CHILD_STORE") + .expect("revoking child requires a destination store"); + let (send, receive) = std::sync::mpsc::channel(); + ctrlc::set_handler(move || { + let _ = send.send(()); + }) + .expect("install marked child TERM handler"); + std::fs::write(ready_path, b"ready").expect("write marked child readiness handshake"); + receive + .recv_timeout(std::time::Duration::from_secs(30)) + .expect("marked child must be stopped within fixture deadline"); + std::fs::copy(source, destination).expect("revoke temporary inline credential"); + return; + } + std::fs::write(ready_path, b"ready").expect("write marked child readiness handshake"); + loop { + std::thread::park_timeout(std::time::Duration::from_secs(60)); + } +} + pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; pub(super) fn expected_owner_only() -> bool { @@ -30,12 +171,13 @@ pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { } /// Construct a minimal record fixture for runtime tests. -pub(super) fn fixture( +pub(in crate::managed_agents) fn fixture( respond_to: RespondTo, allowlist: Vec, auth_tag: Option, ) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".into(), name: "n".into(), diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 57521c04fff..7b9cc9b5eaf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,8 @@ use crate::managed_agents::known_acp_runtime; +#[path = "authority_tests.rs"] +mod authority_tests; + #[path = "cli_tests.rs"] mod cli_tests; @@ -860,6 +863,8 @@ fn receipt_fixture( key: crate::managed_agents::ManagedAgentRuntimeKey, ) -> crate::managed_agents::ManagedAgentRuntimeReceipt { crate::managed_agents::ManagedAgentRuntimeReceipt { + runtime_configuration: None, + authority_version: crate::managed_agents::RUNTIME_AUTHORITY_RECEIPT_VERSION, key, pid: std::process::id(), desktop_instance_id: "test-instance".into(), @@ -895,64 +900,6 @@ fn receipt_validation_rejects_wrong_pair_filename() { )); } -#[test] -fn replacement_removes_receipt_only_after_confirmed_exit() { - use std::cell::{Cell, RefCell}; - - let receipt = receipt_fixture( - crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") - .unwrap(), - ); - let path = std::path::Path::new("pair.json"); - let terminated = Cell::new(None); - let polls = Cell::new(0); - let removed = RefCell::new(None); - - super::terminate_runtime_receipt_with( - path, - &receipt, - |pid| { - terminated.set(Some(pid)); - Ok(()) - }, - |_| { - let poll = polls.get() + 1; - polls.set(poll); - poll < 2 - }, - |path| *removed.borrow_mut() = Some(path.to_path_buf()), - ) - .unwrap(); - - assert_eq!(terminated.get(), Some(receipt.pid)); - assert_eq!(polls.get(), 2); - assert_eq!(removed.into_inner().as_deref(), Some(path)); -} - -#[test] -fn replacement_failure_keeps_receipt() { - use std::cell::Cell; - - let receipt = receipt_fixture( - crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") - .unwrap(), - ); - let removed = Cell::new(false); - let error = super::terminate_runtime_receipt_with( - std::path::Path::new("pair.json"), - &receipt, - |_| Err("signal failed".into()), - |_| false, - |_| removed.set(true), - ) - .unwrap_err(); - - assert_eq!(error, "signal failed"); - assert!(!removed.get()); -} - -// ── workspace pair-key resolution (summary/stop scoping) ──────────────── - #[test] fn unpinned_record_resolves_pair_key_per_workspace() { // Community-scoped truth: an unpinned agent running only on relay A must @@ -967,6 +914,17 @@ fn unpinned_record_resolves_pair_key_per_workspace() { assert!(!runtimes.contains_key(&key_b)); } +#[test] +fn workspace_pair_resolution_distinguishes_loopback_communities() { + let pubkey = "aa".repeat(32); + let localhost = super::resolve_workspace_pair_key(&pubkey, "", "ws://localhost:3000").unwrap(); + let numeric = super::resolve_workspace_pair_key(&pubkey, "", "ws://127.0.0.1:3000").unwrap(); + + let runtimes = std::collections::HashMap::from([(localhost.clone(), ())]); + assert!(runtimes.contains_key(&localhost)); + assert!(!runtimes.contains_key(&numeric)); +} + #[test] fn stored_relay_pin_is_ignored_in_pair_key_resolution() { // Legacy pins are ignored (#2122): a record carrying a creation-era diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..8a002536089 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -3,20 +3,19 @@ use std::sync::atomic::Ordering; use tauri::{AppHandle, Emitter, Manager}; use super::{ - agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, - load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, - process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, - spawn_agent_child, terminate_process, terminate_untracked_pair_runtime, - write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, - ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, - ManagedAgentRuntimeStatus, + agent_readiness, current_instance_id, find_managed_agent_mut, load_global_agent_config, + load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running, + record_agent_command, resolve_effective_agent_env, storage::save_runtime_metadata_batch, + terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt, + AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey, + ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; const STATUS_EVENT: &str = "managed-agent-runtime-status"; -fn status_for( - app: &AppHandle, +fn status_for( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -44,8 +43,8 @@ struct StatusInputs<'a> { global: &'a super::GlobalAgentConfig, } -fn status_for_with( - app: &AppHandle, +fn status_for_with( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -58,6 +57,7 @@ fn status_for_with( let effective = resolve_effective_agent_env(record, personas, metadata, global); let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); ManagedAgentRuntimeStatus { + running_configuration: runtime.and_then(|r| r.spawn_config.runtime_configuration.clone()), pubkey: key.pubkey.clone(), relay_url: key.relay_url.clone(), requested_relay_url, @@ -73,7 +73,7 @@ fn status_for_with( } } -fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { +fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { let _ = app.emit(STATUS_EVENT, status); } @@ -216,7 +216,7 @@ pub async fn list_managed_agent_runtimes( // Records are only mutated above when a runtime exited — skip the store // rewrite on the common nothing-changed poll. if records_changed { - save_managed_agents(&app, &records)?; + save_runtime_metadata_batch(&app, &records)?; } Ok(statuses) }) @@ -224,35 +224,164 @@ pub async fn list_managed_agent_runtimes( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -pub(crate) fn start_managed_agent_runtime_pair_lazy( +#[tauri::command] +pub async fn start_managed_agent_runtime( pubkey: String, relay_url: String, + explicit_start: Option, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, None, app) + start_pair( + pubkey, + relay_url, + None, + explicit_start.unwrap_or(false), + false, + app, + ) + .await } -#[tauri::command] -pub fn start_managed_agent_runtime( +async fn start_pair( pubkey: String, relay_url: String, + expected_record: Option<&super::ManagedAgentRecord>, + explicit_start: bool, + restart: bool, app: AppHandle, ) -> Result { - start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) + start_pair_with_preflight( + pubkey, + relay_url, + expected_record, + explicit_start, + restart, + app.clone(), + |model, allow| async move { + #[cfg(feature = "mesh-llm")] + crate::commands::ensure_relay_mesh_for_record(&app, model.as_deref(), allow).await?; + #[cfg(not(feature = "mesh-llm"))] + let _ = (model, allow); + Ok(()) + }, + ) + .await +} + +/// Direct Start, Restart and reconcile share the same capture/preflight/admit +/// route. Only provider I/O is replaceable in tests; child/receipt paths are real. +pub(crate) async fn start_pair_with_preflight( + pubkey: String, + relay_url: String, + expected_record: Option<&super::ManagedAgentRecord>, + explicit_start: bool, + restart: bool, + app: AppHandle, + preflight: F, +) -> Result +where + R: tauri::Runtime, + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + let state = app.state::(); + let key = ManagedAgentRuntimeKey::new(pubkey.clone(), &relay_url)?; + let owner = state.signing_keys()?.public_key().to_hex(); + let (mut plan, resume, generation) = { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or("Agent not found")?; + let plan = + super::runtime_configurations::capture_for_app(&app, record, &owner, &key.relay_url)?; + if let Some(probed) = expected_record { + // Relay authorization used these identity/access inputs. Ignore + // lifecycle-only timestamp churn from another community's launch, + // not record/definition/config edits or a Stop during that probe. + plan.revalidate( + probed, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + if record.last_stopped_at.is_some() && record.last_stopped_at != probed.last_stopped_at + { + return Err("Stop interrupted runtime reconciliation".into()); + } + } + let resume = if explicit_start && !restart { + Some(super::remote_stop::capture_resume( + &app, + &key, + &key.relay_url, + &owner, + )?) + } else { + None + }; + let generation = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())? + .get(&key) + .map(|r| r.start_nonce.clone()); + (plan, resume, generation) + }; + super::runtime_configurations::preflight_with( + &mut plan, + &owner, + &key.relay_url, + false, + preflight, + ) + .await?; + let app = app.clone(); + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + start_pair_captured_locked( + pubkey, + key.relay_url, + true, + None, + resume.as_ref(), + &plan, + true, + restart, + Some(&generation), + app.clone(), + ) + }) + .await + .map_err(|e| format!("runtime admission task failed: {e}"))? } -fn start_pair( +/// Captured automatic starts additionally fence the durable next-launch selection. +#[allow(clippy::too_many_arguments)] +pub(crate) fn start_pair_captured_locked( pubkey: String, relay_url: String, lazy: bool, expected_updated_at: Option<&str>, - app: AppHandle, + resume: Option<&super::remote_stop::ResumeTicket>, + plan: &super::runtime_configurations::PreparedLaunch, + check_selection: bool, + restart: bool, + expected_generation: Option<&Option>, + app: AppHandle, ) -> Result { let state = app.state::(); - let _transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|e| e.to_string())?; if state.shutdown_started.load(Ordering::Acquire) { return Err("desktop shutdown has started".into()); } @@ -260,7 +389,11 @@ fn start_pair( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; + let mut records = if plan.configuration().is_some() { + super::storage::load_managed_agents_for_launch(&app)? + } else { + load_managed_agents(&app)? + }; let record = find_managed_agent_mut(&mut records, &pubkey)?; if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); @@ -269,48 +402,141 @@ fn start_pair( return Err("managed agent changed while runtime reconciliation was in flight".into()); } let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + let owner = state.signing_keys()?.public_key().to_hex(); + plan.require_preflight()?; + plan.check_scope(Some(&owner), &key.relay_url)?; + if expected_generation.is_some() { + plan.check_continuation(record)?; + } + if check_selection { + super::runtime_configurations::check_selection( + record, + Some(&owner), + &key.relay_url, + plan.configuration().as_ref(), + )?; + } + plan.revalidate( + record, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + super::remote_stop::check_launch(&app, &key, &key.relay_url, Some(&owner), resume)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; + if expected_generation.is_some_and(|expected| { + &runtimes + .get(&key) + .map(|runtime| runtime.start_nonce.clone()) + != expected + }) { + return Err("Runtime generation changed during preflight; retry Start".into()); + } + if restart { + // Validate target, Stop/placement and generation BEFORE touching the old + // child. Store lock stays held through teardown, spawn and receipt. + super::with_pair_runtime_receipt_authority(&app, &key, || { + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; + if runtimes.contains_key(&key) { + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; + } + Ok(()) + })?; + } if runtimes .get_mut(&key) .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) { let status = status_for(&app, record, &key, runtimes.get(&key), None); + let requested = plan.configuration(); + if status.running_configuration != requested { + return Err("A different configuration is running; Stop before Start".into()); + } return Ok(status); } runtimes.remove(&key); terminate_untracked_pair_runtime(&app, &key)?; - let owner = state - .keys - .lock() - .ok() - .map(|keys| keys.public_key().to_hex()); - let mut process = - spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; - let now = crate::util::now_iso(); - let receipt = ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: current_instance_id(&app), - started_at: now.clone(), - }; - if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { - let _ = terminate_process(process.child.id()); - let _ = process.child.wait(); - return Err(error); + if restart { + record.last_stopped_at = Some(crate::util::now_iso()); + state.clear_agent_session_cache(&key); } + let process_result = (|| { + if plan.configuration().is_some() { + // Stop may take time. A credential revoked during teardown must not + // be resurrected from the captured plan or the pre-Stop record. + record.private_key_nsec.clear(); + let fresh = super::storage::load_managed_agents_for_launch(&app)?; + let current = fresh + .iter() + .find(|r| r.pubkey == record.pubkey) + .ok_or("Agent removed during launch")?; + plan.revalidate( + current, + &load_personas(&app)?, + &load_global_agent_config(&app)?, + )?; + record.private_key_nsec = current.private_key_nsec.clone(); + } + let mut process = super::spawn_agent_child_prepared( + &app, + record, + &relay_url, + lazy, + Some(&owner), + None, + resume, + Some(plan), + )?; + let mut receipt = ManagedAgentRuntimeReceipt::new( + key.clone(), + process.child.id(), + current_instance_id(&app), + crate::util::now_iso(), + ); + receipt.runtime_configuration = process.spawn_config.runtime_configuration.clone(); + if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { + let _ = terminate_process(process.child.id()); + let _ = process.child.wait(); + return Err(error); + } + Ok(process) + })(); + let process = match process_result { + Ok(process) => process, + Err(error) if restart => { + // Teardown succeeded but launch failed. Return an honest existing + // Failed status (no PID) so the UI can retire only the old turns. + // Preflight/admission/Stop failures above still return Err and leave + // the old child's badge alone. + record.last_error = Some(error.clone()); + record.updated_at = crate::util::now_iso(); + let mut status = status_for(&app, record, &key, None, None); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + drop(runtimes); + super::storage::save_runtime_metadata(&app, record)?; + emit_status(&app, &status); + return Ok(status); + } + Err(error) => return Err(error), + }; + let now = crate::util::now_iso(); record.runtime_pid = None; record.updated_at = now.clone(); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_error = None; runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + super::remote_stop::finish_resume(&app, &key, &relay_url, Some(&owner), resume)?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); - save_managed_agents(&app, &records)?; + super::storage::save_runtime_metadata(&app, record)?; emit_status(&app, &status); Ok(status) } @@ -326,6 +552,16 @@ pub fn stop_managed_agent_runtime( .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; + stop_pair_locked(pubkey, relay_url, app.clone()) +} + +// Caller owns managed_agent_runtime_transition for the whole admission/effect. +pub(crate) fn stop_pair_locked( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + let state = app.state::(); let _store = state .managed_agents_store_lock .lock() @@ -337,59 +573,59 @@ pub fn stop_managed_agent_runtime( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if let Some(mut runtime) = runtimes.remove(&key) { - let stop_result = if process_is_running(runtime.child.id()) { - terminate_process(runtime.child.id()) + // V0 receipt normalization was lossy. Wrap every tracked/untracked Stop + // side effect so an ambiguous receipt cannot be killed, deleted, cleared + // from cache, persisted as stopped, or reported stopped. + let status = super::with_pair_runtime_receipt_authority(&app, &key, || { + if runtimes.contains_key(&key) { + // Use ordinary Desktop Stop, including its platform-specific + // child/job ownership. Remote control must not grow a second + // teardown contract. + super::stop_managed_agent_pair(&app, record, &mut runtimes, &key)?; } else { - Ok(()) + terminate_untracked_pair_runtime(&app, &key)?; } - .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); - match stop_result { - Ok(status) => { - record.last_exit_code = status.code(); - let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); - } - Err(error) => { - // Keep failed teardown visible/manageable instead of - // orphaning it: the child stays tracked and the receipt - // stays on disk until a stop actually succeeds. - runtimes.insert(key, runtime); - return Err(error); - } + // Old scalar records have no community-bound receipt. Do not erase a + // live child or claim success when this request cannot establish scope. + reject_unscoped_live_child( + record.runtime_pid.filter(|pid| process_is_running(*pid)), + runtimes.values().map(|runtime| runtime.child.id()), + )?; + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + if record + .runtime_pid + .is_some_and(|pid| !process_is_running(pid)) + { + record.runtime_pid = None; } - } else { - // No runtime is tracked at this key, but a valid prior-session - // receipt may still point at a live child (e.g. the crash-recovery - // window for a non-auto-start agent). Terminate that orphan before - // erasing its receipt — otherwise this "stop" leaves the harness - // running yet deletes the one artifact sweeps and - // terminate_untracked_pair_runtime use to find it, and a follow-up - // start would spawn a duplicate harness for the same pair. On - // failure the receipt stays on disk (terminate_untracked_pair_runtime - // only removes it after the child exits), mirroring the tracked - // path's keep-until-success invariant. - terminate_untracked_pair_runtime(&app, &key)?; - } - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - record.runtime_pid = None; - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for(&app, record, &key, None, None); + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + Ok(status_for(&app, record, &key, None, None)) + })?; drop(runtimes); - save_managed_agents(&app, &records)?; + super::storage::save_runtime_metadata(&app, record)?; emit_status(&app, &status); Ok(status) } +fn reject_unscoped_live_child( + live_pid: Option, + tracked: impl Iterator, +) -> Result<(), String> { + if live_pid.is_some_and(|pid| !tracked.into_iter().any(|other| other == pid)) { + return Err("Legacy runtime is not bound to this community; use local Desktop Stop".into()); + } + Ok(()) +} + #[tauri::command] -pub fn restart_managed_agent_runtime( +pub async fn restart_managed_agent_runtime( pubkey: String, relay_url: String, app: AppHandle, ) -> Result { - stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, None, false, true, app).await } /// Probe whether this agent can operate on `requested_relay_url`. @@ -409,7 +645,7 @@ async fn probe_agent_relay_access( let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; - let api_base = crate::relay::relay_http_base_url(&key.relay_url); + let api_base = crate::relay::relay_http_base_url(&requested_relay_url); tokio::time::timeout( std::time::Duration::from_secs(10), crate::relay::query_relay_at_with_keys( @@ -441,6 +677,7 @@ fn unkeyable_failed_status( let metadata = super::known_acp_runtime(&command); let effective = resolve_effective_agent_env(record, personas, metadata, global); ManagedAgentRuntimeStatus { + running_configuration: None, pubkey: record.pubkey.clone(), relay_url: requested.clone(), requested_relay_url: Some(requested), @@ -497,81 +734,75 @@ pub async fn reconcile_managed_agent_runtimes( .collect() .await; - // start_pair does blocking work (std mutexes, process spawn, receipt - // writes, and up-to-2s exit polling in terminate_untracked_pair_runtime), - // so run the post-probe start loop off the async workers, matching the - // restart flows. - tokio::task::spawn_blocking(move || { - let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); - let mut rows = Vec::new(); - for probe in probes { - match probe { - Ok((record, key, requested)) => { - match start_pair( - record.pubkey.clone(), - key.relay_url.clone(), - true, - Some(&record.updated_at), - app.clone(), - ) { - Ok(mut status) => { - status.requested_relay_url = Some(requested); - rows.push(status); - } - Err(error) => { - let mut status = status_for_with( - &app, - &record, - &key, - None, - Some(requested), - StatusInputs { - personas: &personas, - global: &global, - }, - ); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - rows.push(status); - } + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let mut rows = Vec::new(); + for probe in probes { + match probe { + Ok((record, key, requested)) => { + match start_pair( + record.pubkey.clone(), + requested.clone(), + Some(&record), + false, + false, + app.clone(), + ) + .await + { + Ok(mut status) => { + status.requested_relay_url = Some(requested); + rows.push(status); + } + Err(error) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + rows.push(status); } - } - Err((record, requested, error)) => { - // Per-community degradation: a relay URL that cannot even - // form a pair key gets a Failed row (with the raw - // requested URL) like any other probe failure, instead of - // aborting every other community's row. - let status = - match ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested) { - Ok(key) => { - let mut status = status_for_with( - &app, - &record, - &key, - None, - Some(requested), - StatusInputs { - personas: &personas, - global: &global, - }, - ); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - status - } - Err(_) => unkeyable_failed_status( - &record, requested, error, &personas, &global, - ), - }; - rows.push(status); } } + Err((record, requested, error)) => { + // Per-community degradation: a relay URL that cannot even + // form a pair key gets a Failed row (with the raw + // requested URL) like any other probe failure, instead of + // aborting every other community's row. + let status = match ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested) { + Ok(key) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + status + } + Err(_) => { + unkeyable_failed_status(&record, requested, error, &personas, &global) + } + }; + rows.push(status); + } } - rows - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}")) + } + Ok(rows) } #[cfg(test)] @@ -698,6 +929,24 @@ mod tests { assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); } + #[test] + fn observer_lifecycle_key_does_not_cross_loopback_communities() { + let localhost = payload( + "ws://localhost:3000", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + let numeric = payload( + "ws://127.0.0.1:3000", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert_ne!( + observer_lifecycle_key(&localhost.pubkey, &localhost).unwrap(), + observer_lifecycle_key(&numeric.pubkey, &numeric).unwrap() + ); + } + #[test] fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { let ready = payload( @@ -732,3 +981,171 @@ mod tests { assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); } } + +#[cfg(test)] +mod stop_scope_tests { + use super::reject_unscoped_live_child; + + #[cfg(unix)] + fn assert_remote_stop_effect_is_blocked(tracked: bool) { + use tauri::Manager as _; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_xdg = std::env::var_os("XDG_DATA_HOME"); + struct RestoreEnv(Option, Option); + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.1.take() { + Some(value) => std::env::set_var("XDG_DATA_HOME", value), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + let _restore_env = RestoreEnv(old_home, old_xdg); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let requested_relay = "wss://relay.example/room/"; + let stored_relay = "wss://relay.example/room"; + let pubkey = "aa".repeat(32); + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let instance_id = super::super::current_instance_id(app.handle()); + let mut child = Some( + super::super::runtime::test_fixtures::MarkedTestChild::spawn(&instance_id).unwrap(), + ); + let pid = child.as_ref().unwrap().id(); + let _process_guard = super::super::runtime::test_fixtures::MarkedProcessGuard::new(pid); + assert!(super::super::process_has_buzz_marker(pid, &instance_id)); + + let mut record = super::super::runtime::test_fixtures::fixture( + super::super::RespondTo::OwnerOnly, + Vec::new(), + None, + ); + record.pubkey = pubkey.clone(); + record.updated_at = "before".into(); + super::super::storage::save_managed_agents_with_new_keys(app.handle(), &[record.clone()]) + .unwrap(); + + let stored_key = super::ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); + let requested_key = super::ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); + let receipt = super::ManagedAgentRuntimeReceipt { + runtime_configuration: None, + authority_version: 0, + key: stored_key.clone(), + pid, + desktop_instance_id: instance_id, + started_at: "now".into(), + }; + super::super::write_agent_runtime_receipt(app.handle(), &receipt).unwrap(); + + if tracked { + let process = crate::managed_agents::ManagedAgentProcess { + child: child.take().unwrap().into_child(), + log_path: Default::default(), + spawn_config: + crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &record, + &[], + &[], + requested_relay, + &Default::default(), + false, + crate::managed_agents::AcpSessionPolicy::Channel, + ), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".into(), + }; + app.state::() + .managed_agent_processes + .lock() + .unwrap() + .insert( + requested_key.clone(), + super::ManagedAgentPairRuntime::starting(process), + ); + } + + let cache: crate::managed_agents::config_bridge::SessionConfigCache = + serde_json::from_value(serde_json::json!({ + "configOptions": [], + "availableModes": [], + "availableModels": [], + "currentModel": null, + "modelOverridden": false, + "gooseNativeConfig": null, + "capturedAt": "now" + })) + .unwrap(); + app.state::() + .put_session_cache(requested_key.clone(), cache); + + let error = + super::stop_pair_locked(pubkey.clone(), requested_relay.into(), app.handle().clone()) + .unwrap_err(); + assert!(error.contains("cannot prove the requested community authority")); + assert_eq!( + super::super::load_managed_agents(app.handle()).unwrap()[0].updated_at, + "before" + ); + assert!(app + .state::() + .get_session_cache(&requested_key) + .is_some()); + assert!(super::super::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, candidate)| candidate == &receipt)); + + if tracked { + let mut runtime = app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .remove(&requested_key) + .unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } else { + assert!(child + .as_mut() + .unwrap() + .child_mut() + .try_wait() + .unwrap() + .is_none()); + } + super::super::remove_agent_runtime_receipt(app.handle(), &stored_key); + } + + #[test] + fn live_legacy_child_cannot_be_erased_or_reported_stopped() { + assert!(reject_unscoped_live_child(Some(12), [].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [13].into_iter()).is_err()); + assert!(reject_unscoped_live_child(Some(12), [12].into_iter()).is_ok()); + assert!(reject_unscoped_live_child(None, [13].into_iter()).is_ok()); + } + + #[cfg(unix)] + #[test] + fn tracked_remote_stop_has_no_effect_before_ambiguous_receipt_refusal() { + assert_remote_stop_effect_is_blocked(true); + } + + #[cfg(unix)] + #[test] + fn untracked_remote_stop_has_no_effect_before_ambiguous_receipt_refusal() { + assert_remote_stop_effect_is_blocked(false); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs new file mode 100644 index 00000000000..2d505e752c8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations.rs @@ -0,0 +1,621 @@ +//! Named next-launch settings. Identity, persona and memory remain on the agent. +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{readiness::EffectiveHarnessDescriptor, ManagedAgentRecord}; + +/// One destination-local configuration; references contain names, never credentials. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfiguration { + pub id: String, + pub revision: String, + pub name: String, + pub host: String, + pub runtime: String, + pub model: String, + pub provider: Option, + pub workspace: Option, + /// Target environment key -> key in the existing host-local configuration. + #[serde(default)] + pub credential_refs: BTreeMap, +} + +pub use buzz_core_pkg::desktop_lifecycle::RuntimeConfigurationRef; +mod store; +pub(crate) use store::selected_reference; +pub use store::RuntimeConfigurationStore; + +impl RuntimeConfiguration { + pub(crate) fn reference(&self) -> RuntimeConfigurationRef { + RuntimeConfigurationRef { + id: self.id.clone(), + revision: self.revision.clone(), + } + } +} + +/// Native-only immutable inputs. Never serialize resolved environment or identity keys. +pub(crate) struct PreparedLaunch { + pub(super) record: ManagedAgentRecord, + pub(super) descriptor: EffectiveHarnessDescriptor, + pub(super) effective: super::effective_config::EffectiveAgentConfig, + legacy_default: bool, + preflight_complete: bool, + expires_at: Option, + host: String, + scope: (String, String), + // Catalog-only preparation has no app context and cannot be executed. + pub(super) app_inputs: Option<(Option, super::AcpSessionPolicy)>, +} + +impl PreparedLaunch { + pub(crate) fn check_scope(&self, owner: Option<&str>, community: &str) -> Result<(), String> { + if self.app_inputs.is_none() { + return Err("Launch plan has no app inputs".into()); + } + if Some(self.scope.0.as_str()) != owner || self.scope.1 != community { + return Err("Prepared runtime launch belongs to another owner or community".into()); + } + Ok(()) + } + + /// Bound a remote request through destructive Stop as well as async preflight. + pub(crate) fn expire_at(&mut self, deadline: u64) { + self.expires_at = Some(self.expires_at.map_or(deadline, |old| old.min(deadline))); + } + + /// Preparation is not launch authority. Only the successful ordinary async + /// provider boundary can authorize these exact inputs for shared spawn. + pub(crate) fn require_preflight(&self) -> Result<(), String> { + if self + .expires_at + .is_some_and(|deadline| nostr::Timestamp::now().as_secs() >= deadline) + { + return Err("Runtime launch request expired".into()); + } + if !self.preflight_complete { + return Err("Captured runtime launch has not completed provider preflight".into()); + } + Ok(()) + } + + pub(crate) fn check_continuation(&self, record: &ManagedAgentRecord) -> Result<(), String> { + if self.record.last_stopped_at != record.last_stopped_at { + return Err("Stop interrupted runtime preflight; retry Start".into()); + } + Ok(()) + } + + pub(crate) fn configuration(&self) -> Option { + selected(&self.record) + .ok() + .flatten() + .map(RuntimeConfiguration::reference) + } + + /// Fail closed if the record or any resolved launch prerequisite changed. + pub(crate) fn revalidate( + &self, + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + ) -> Result<(), String> { + // Ignore lifecycle bookkeeping and other scopes, not launch inputs. A confirmed + // Stop changes timestamps but must not invalidate an otherwise exact plan. + let mut comparable = record.clone(); + comparable.runtime_configurations = self.record.runtime_configurations.clone(); + comparable.updated_at = self.record.updated_at.clone(); + comparable.runtime_pid = self.record.runtime_pid; + comparable.last_started_at = self.record.last_started_at.clone(); + comparable.last_stopped_at = self.record.last_stopped_at.clone(); + comparable.last_exit_code = self.record.last_exit_code; + comparable.last_error = self.record.last_error.clone(); + comparable.last_error_code = self.record.last_error_code; + if comparable != self.record { + return Err("Agent changed during runtime preflight; retry Start".into()); + } + let current = if self.legacy_default { + prepare_default(record, personas, global, &self.scope.0, &self.scope.1)? + } else { + prepare( + record, + self.configuration().as_ref(), + personas, + global, + &self.host, + &self.scope.0, + &self.scope.1, + )? + }; + if selected(¤t.record)? != selected(&self.record)? + || current.descriptor != self.descriptor + || current.effective != self.effective + { + return Err("Launch settings changed during runtime preflight; retry Start".into()); + } + Ok(()) + } +} + +/// Resolve an explicit reference without mutating the agent's durable selection. +/// `None` explicitly means legacy Default, never "read selection later". +pub(crate) fn prepare( + record: &ManagedAgentRecord, + reference: Option<&RuntimeConfigurationRef>, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + host: &str, + owner: &str, + community: &str, +) -> Result { + verify_owner(record, owner)?; + let mut projected = record.clone(); + let configurations = record.runtime_configurations.get(owner, community); + configurations.validate()?; + projected.runtime_configurations.launch = reference.map(|reference| { + configurations.entries.iter() + .find(|c| c.id == reference.id && c.revision == reference.revision && c.host == host) + .cloned().ok_or_else(|| "Runtime configuration is unavailable in this Desktop scope or its revision changed".to_string()) + }).transpose()?; + let descriptor = super::resolve_effective_harness_descriptor(&projected, personas, global)?; + preflight(&projected, &descriptor, host)?; + let effective = super::effective_config::resolve_effective_config(&projected, personas, global) + .require_resolved()?; + Ok(PreparedLaunch { + record: projected, + descriptor, + effective, + legacy_default: false, + preflight_complete: false, + expires_at: None, + host: host.into(), + scope: (owner.into(), community.into()), + app_inputs: None, + }) +} + +// Default preserves legacy readiness/setup-listener and unattested-record behavior, +// but its effective inputs must still be immutable across async preflight. +fn prepare_default( + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + owner: &str, + community: &str, +) -> Result { + let mut record = record.clone(); + record.runtime_configurations.launch = None; + Ok(PreparedLaunch { + descriptor: super::resolve_effective_harness_descriptor(&record, personas, global)?, + effective: super::effective_config::resolve_effective_config(&record, personas, global) + .require_resolved()?, + record, + legacy_default: true, + preflight_complete: false, + expires_at: None, + host: String::new(), + scope: (owner.into(), community.into()), + app_inputs: None, + }) +} + +/// Capture ordinary next-launch authority, including legacy Default. Never read +/// selection again to choose a launch after preflight; only check it at admission. +pub(crate) fn capture_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + owner: &str, + community: &str, +) -> Result { + use tauri::Manager; + if let Some(reference) = selected_reference(record, Some(owner), community)? { + return prepare_for_app(app, record, Some(&reference), owner, community); + } + let mut plan = prepare_default( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + owner, + community, + )?; + plan.app_inputs = Some(( + super::spawn_snapshot::effective_team_instructions(record, &super::load_teams(app)?), + super::acp_session_policy(app.state::().inner()), + )); + Ok(plan) +} + +/// Absent selection is the legacy Default configuration, with unchanged inheritance. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfigurations { + pub selected: Option, + pub entries: Vec, +} + +impl RuntimeConfigurations { + pub(crate) fn selected(&self) -> Result, String> { + self.selected + .as_ref() + .map(|id| { + self.entries + .iter() + .find(|entry| &entry.id == id) + .ok_or_else(|| "Selected runtime configuration is missing".to_string()) + }) + .transpose() + } + + pub(crate) fn validate(&self) -> Result<(), String> { + if self.entries.len() > 32 { + return Err("At most 32 runtime configurations are supported".into()); + } + self.selected()?; + let mut ids = std::collections::BTreeSet::new(); + for entry in &self.entries { + if !ids.insert(&entry.id) + || uuid::Uuid::parse_str(&entry.id).is_err() + || uuid::Uuid::parse_str(&entry.revision).is_err() + || entry.host.trim().is_empty() + || entry.host.len() > 128 + || entry.name.trim().is_empty() + || entry.name.len() > 120 + || entry.model.trim().is_empty() + || entry.model.len() > 512 + || entry.runtime.trim().is_empty() + || entry.runtime.len() > 128 + || [&entry.name, &entry.runtime, &entry.model] + .iter() + .any(|text| text.chars().any(char::is_control)) + || entry.provider.as_ref().is_some_and(|text| { + text.trim().is_empty() || text.len() > 128 || text.chars().any(char::is_control) + }) + || entry.credential_refs.len() > 32 + || entry + .workspace + .as_ref() + .is_some_and(|p| p.len() > 4096 || !std::path::Path::new(p).is_absolute()) + || entry + .credential_refs + .iter() + .any(|(key, source)| !reference_name(key) || !reference_name(source)) + { + return Err("Invalid destination-local runtime configuration".into()); + } + } + Ok(()) + } +} + +fn reference_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + && !super::env_vars::is_reserved_env_key(value) +} + +pub(crate) fn selected( + record: &ManagedAgentRecord, +) -> Result, String> { + Ok(record.runtime_configurations.launch.as_ref()) +} + +/// Apply pins after inherited env: saved environment cannot silently override the pick. +pub(crate) fn apply_descriptor( + record: &ManagedAgentRecord, + descriptor: &mut EffectiveHarnessDescriptor, +) -> Result<(), String> { + let Some(config) = selected(record)? else { + return Ok(()); + }; + let runtime = super::known_acp_runtime(&descriptor.command) + .ok_or("Named configuration requires a catalogued runtime")?; + if runtime.provider_locked && config.provider.is_some() { + return Err("Selected provider is not supported by this runtime".into()); + } + let references = config + .credential_refs + .iter() + .map(|(target, source)| { + if !reference_name(target) || !reference_name(source) { + return Err("Invalid credential reference".to_string()); + } + descriptor + .env + .get(source) + .filter(|v| !v.trim().is_empty()) + .cloned() + .map(|value| (target.clone(), value)) + .ok_or_else(|| "A local credential reference is unavailable".to_string()) + }) + .collect::, _>>()?; + descriptor.env.extend(references); + for (key, value) in super::runtime::runtime_metadata_env_vars( + runtime.model_env_var, + runtime.provider_env_var, + runtime.provider_locked, + Some(&config.model), + config.provider.as_deref(), + ) { + descriptor.env.insert(key.into(), value.into()); + } + Ok(()) +} + +/// Recheck before launch, never substitute a setup listener for the selected runtime. +pub(crate) fn preflight( + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + host: &str, +) -> Result<(), String> { + let config = selected(record)?; + if config.is_some_and(|config| config.host != host) { + return Err("Runtime configuration belongs to another Desktop".into()); + } + if let Some(error) = super::storage::spawn_key_refusal(record) { + return Err(error); + } + let keys = nostr::Keys::parse(record.private_key_nsec.trim()) + .map_err(|_| "Local identity access is unavailable")?; + if keys.public_key().to_hex() != record.pubkey { + return Err("Local identity does not match this agent".into()); + } + if !super::resolve_command(&record.acp_command) + .is_some_and(|path| super::discovery::is_executable_file(&path)) + { + return Err("ACP runtime is unavailable".into()); + } + if !super::resolve_command(&descriptor.command) + .is_some_and(|path| super::discovery::is_executable_file(&path)) + { + return Err("Selected runtime is unavailable on this Desktop".into()); + } + if config.is_some() { + required_mcp_command(&descriptor.command)?; + } + if config + .and_then(|c| c.workspace.as_ref()) + .is_some_and(|path| !std::path::Path::new(path).is_dir()) + { + return Err("Selected workspace is unavailable on this Desktop".into()); + } + let effective = super::readiness::EffectiveAgentEnv { + env: descriptor.env.clone(), + effective_command: descriptor.command.clone(), + config_file_path: super::known_acp_runtime(&descriptor.command) + .and_then(|r| r.config_file_path), + }; + if !matches!( + super::agent_readiness(&effective), + super::AgentReadiness::Ready + ) { + return Err("Selected configuration is not ready on this Desktop; check runtime and local credentials".into()); + } + Ok(()) +} + +fn verify_owner(record: &ManagedAgentRecord, owner: &str) -> Result<(), String> { + let verified = record.auth_tag.as_deref().and_then(|tag| { + let agent = nostr::PublicKey::from_hex(&record.pubkey).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(tag, &agent).ok() + }); + if record.backend != super::BackendKind::Local + || verified.is_none_or(|key| key.to_hex() != owner) + { + return Err("Agent ownership is unavailable in this scope".into()); + } + Ok(()) +} + +/// Resolve on this owner/community's Desktop using the canonical local host identity. +pub(crate) fn prepare_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + reference: Option<&RuntimeConfigurationRef>, + owner: &str, + community: &str, +) -> Result { + use tauri::Manager; + let host = local_host(app, owner, community)?; + let mut plan = prepare( + record, + reference, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + &host, + owner, + community, + )?; + plan.app_inputs = Some(( + super::spawn_snapshot::effective_team_instructions(record, &super::load_teams(app)?), + super::acp_session_policy(app.state::().inner()), + )); + Ok(plan) +} + +fn local_host( + app: &tauri::AppHandle, + owner: &str, + community: &str, +) -> Result { + use tauri::Manager; + let state = app.state::(); + let keys = state.signing_keys()?; + if keys.public_key().to_hex() != owner { + return Err("Desktop owner changed".into()); + } + let scope = super::retention::RetentionScope { + db_path: super::retention::scoped_retention_db_path( + &super::managed_agents_base_dir(app)?, + community, + owner, + ), + relay_url: community.into(), + owner_keys: keys, + }; + // `managed_agents_base_dir` only creates `agents/`; the scoped + // `retention/` parent is normally ensured lazily by + // `active_retention_scope` during event retention, which a first + // lifecycle message can precede on a fresh install (and callers that + // bypass app startup, like tests, never trigger). Same on-demand parent + // ensure as `remote_stop::connection` so opening the scoped identity db + // is safe for every fresh-state caller. + if let Some(parent) = scope.db_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create retention scope directory: {error}"))?; + } + crate::commands::desktop_stop::local_id( + &mut super::retention::open_retention_db(&scope.db_path)?, + &scope, + ) +} + +/// Owner-private launch choices; deliberately excludes workspace, key references and env. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeConfigurationSummary { + pub configuration: Option, + pub name: String, + pub host: String, + pub runtime: String, + pub model: Option, + pub provider: Option, + pub eligible: bool, +} + +/// Shared readiness projection. A missing/unknown prerequisite is never eligible. +pub(crate) fn catalog( + record: &ManagedAgentRecord, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, + host: &str, + owner: &str, + community: &str, +) -> Vec { + let configurations = record.runtime_configurations.get(owner, community); + std::iter::once(None) + .chain(configurations.entries.iter().map(Some)) + .map(|config| { + let reference = config.map(RuntimeConfiguration::reference); + let mut projected = record.clone(); + projected.runtime_configurations.launch = config.cloned(); + let effective = + super::effective_config::resolve_effective_config(&projected, personas, global) + .require_resolved() + .ok(); + RuntimeConfigurationSummary { + configuration: reference.clone(), + name: config + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Default".into()), + host: config + .map(|c| c.host.clone()) + .unwrap_or_else(|| host.into()), + runtime: config + .map(|c| c.runtime.clone()) + .unwrap_or_else(|| super::record_agent_command(&projected, personas)), + model: effective.as_ref().and_then(|e| e.model.value.clone()), + provider: effective.and_then(|e| e.provider.value), + eligible: prepare( + record, + reference.as_ref(), + personas, + global, + host, + owner, + community, + ) + .is_ok(), + } + }) + .collect() +} + +/// Fence ordinary next-launch selection, including Default, across preflight. +pub(crate) fn check_selection( + record: &ManagedAgentRecord, + owner: Option<&str>, + community: &str, + expected: Option<&RuntimeConfigurationRef>, +) -> Result<(), String> { + if selected_reference(record, owner, community)?.as_ref() != expected { + return Err("Selected configuration changed during preflight".into()); + } + Ok(()) +} + +/// Scoped safe catalog for lifecycle consumers; never exposes the global configuration store. +pub(crate) fn catalog_for_app( + app: &tauri::AppHandle, + record: &ManagedAgentRecord, + owner: &str, + community: &str, +) -> Result, String> { + verify_owner(record, owner)?; + let host = local_host(app, owner, community)?; + Ok(catalog( + record, + &super::load_personas(app)?, + &super::load_global_agent_config(app)?, + &host, + owner, + community, + )) +} + +/// Run the ordinary provider preflight against this captured plan. The callback +/// is the existing async mesh boundary (fixture-controlled in orchestration tests). +pub(crate) async fn preflight_with( + plan: &mut PreparedLaunch, + owner: &str, + community: &str, + allow_create: bool, + preflight: F, +) -> Result<(), String> +where + F: FnOnce(Option, bool) -> Fut, + Fut: std::future::Future>, +{ + plan.check_scope(Some(owner), community)?; + plan.preflight_complete = false; + preflight(plan.effective.relay_mesh_model_id(), allow_create).await?; + plan.preflight_complete = true; + Ok(()) +} + +/// Named runtime selection includes its catalog-declared MCP tool server. Unlike +/// Default's optional inherited capability, an unavailable declared tool is an error. +pub(crate) fn required_mcp_command(command: &str) -> Result, String> { + super::known_acp_runtime(command) + .and_then(|runtime| runtime.mcp_command) + .map(|command| { + super::resolve_command(command) + .filter(|path| super::discovery::is_executable_file(path)) + .ok_or_else(|| "Selected runtime's required MCP tool is unavailable".to_string()) + }) + .transpose() +} + +/// Final authority after all inherited/harness/mesh environment writes. This is +/// an ACP verification target, not an assertion that the model is ready. +pub(crate) fn apply_required_model_env( + command: &mut std::process::Command, + required: Option<&str>, +) -> Result<(), String> { + command.env_remove("BUZZ_ACP_REQUIRE_MODEL"); + command.env_remove("BUZZ_ACP_REQUIRED_MODEL"); + if let Some(model) = required { + if model.trim().is_empty() { + return Err("Named launch has no resolved model".into()); + } + command.env("BUZZ_ACP_REQUIRED_MODEL", model); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs new file mode 100644 index 00000000000..75b9cb35ace --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs @@ -0,0 +1,734 @@ +//! Production orchestration with only provider I/O, process sweeps and relay +//! publication replaced. No external providers or system agent enumeration. +use super::*; +use std::{cell::RefCell, os::unix::fs::PermissionsExt, sync::atomic::AtomicBool}; +use tauri::Manager; + +const ONE: &str = "wss://launch-one.example"; +const TWO: &str = "wss://launch-two.example"; + +struct Fixture { + app: tauri::App, + record: ManagedAgentRecord, + owner: String, + env: Vec<(&'static str, Option)>, + temp: tempfile::TempDir, +} +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let env = ["HOME", "XDG_DATA_HOME", "PATH"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", temp.path().display())); + agents::clear_resolve_cache(); + for name in ["buzz-agent", "buzz-dev-mcp", "buzz-acp"] { + let path = temp.path().join(name); + let script = if name == "buzz-acp" { + format!("#!/bin/sh\nprintf '%s|%s|%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_ACP_REQUIRED_MODEL\" \"$BUZZ_ACP_LAZY_POOL\" >> '{}'\n", temp.path().join("launches").display()) + } else { + "#!/bin/sh\nexit 0\n".into() + }; + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let state = app.state::(); + *state.relay_url_override.lock().unwrap() = Some(ONE.into()); + let mut record = record(); + let owner = attest(&mut record, &state.signing_keys().unwrap()); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.runtime = Some("buzz-agent".into()); + record.agent_command = "buzz-agent".into(); + record.model = Some("default-model".into()); + record.provider = Some("openai".into()); + record.start_on_app_launch = true; + record + .env_vars + .insert("OPENAI_COMPAT_API_KEY".into(), "fixture-only".into()); + Self { + app, + record, + owner, + env, + temp, + } + } + fn named(&mut self, relay: &str, provider: &str, model: &str) -> RuntimeConfiguration { + let host = local_host(self.app.handle(), &self.owner, relay).unwrap(); + let mut entry = config(&host); + entry.provider = Some(provider.into()); + entry.model = model.into(); + save(&mut self.record, &self.owner, relay, entry) + } + fn persist(&self) { + agents::storage::save_managed_agents_with_new_keys( + self.app.handle(), + std::slice::from_ref(&self.record), + ) + .unwrap(); + } + fn finish_children(&self) -> Vec<(String, Option)> { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + runtimes + .iter_mut() + .map(|(key, runtime)| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if let Some(status) = runtime.child.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!( + std::time::Instant::now() < deadline, + "fixture child timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + ( + key.relay_url.clone(), + runtime.spawn_config.runtime_configuration.clone(), + ) + }) + .collect() + } + fn no_launch(&self) { + assert!(self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .is_empty()); + assert!(!self.temp.path().join("launches").exists()); + assert!(agents::read_all_agent_runtime_receipts(self.app.handle()).is_empty()); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + // Only owned fixture children; never call system-wide cleanup helpers. + for (_, mut runtime) in self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .drain() + { + let _ = runtime.child.kill(); + let _ = runtime.child.wait(); + } + for (key, value) in self.env.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + agents::clear_resolve_cache(); + } +} + +#[tokio::test] +async fn ordinary_default_start_cannot_launch_new_selection_after_preflight() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = crate::commands::start_local_agent_with_preflight_using( + &app, + &state, + &pubkey, + crate::commands::LocalStartIntent::Explicit, + None, + None, + None, + None, + |model, _| async move { + assert_eq!(model, None); // Default is openai, not the later mesh pick. + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture.named(ONE, "relay-mesh", "never-preflighted"); + fixture.persist(); + resume.send(()).unwrap(); + assert!(future + .await + .unwrap_err() + .contains("Selected configuration changed")); + fixture.no_launch(); +} + +#[tokio::test] +async fn restore_preflights_selected_provider_in_both_directions() { + let _guard = agents::lock_path_mutex_async().await; + for (default_provider, named_provider, expected) in [ + ("relay-mesh", "openai", None), + ("openai", "relay-mesh", Some("selected-model")), + ] { + let mut fixture = Fixture::new(); + fixture.record.provider = Some(default_provider.into()); + let named = fixture.named(ONE, named_provider, "selected-model"); + fixture.persist(); + let calls = RefCell::new(Vec::new()); + let sweeps = RefCell::new(0); + let published = RefCell::new(Vec::new()); + agents::restore_with( + fixture.app.handle(), + &AtomicBool::new(false), + |model, _| { + calls.borrow_mut().push(model.clone()); + async move { + assert_eq!(model.as_deref(), expected); + Ok(()) + } + }, + |pids| { + assert!(pids.is_empty()); + *sweeps.borrow_mut() += 1; + }, + |key, _| published.borrow_mut().push(key), + ) + .await + .unwrap(); + assert_eq!(*calls.borrow(), vec![expected.map(str::to_owned)]); + assert_eq!(*sweeps.borrow(), 1); + assert_eq!(*published.borrow(), vec![fixture.record.pubkey.clone()]); + assert_eq!( + fixture.finish_children(), + vec![(ONE.into(), Some(named.reference()))] + ); + assert!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .starts_with("selected-model|selected-model|") + ); + } +} + +#[tokio::test] +async fn restore_refuses_provider_preflight_failure_before_spawn() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + fixture.named(ONE, "relay-mesh", "offline-model"); + fixture.persist(); + agents::restore_with( + fixture.app.handle(), + &AtomicBool::new(false), + |model, _| async move { + assert_eq!(model.as_deref(), Some("offline-model")); + Err("fixture peer offline".into()) + }, + |_| {}, + |_, _| panic!("failed restore must not publish"), + ) + .await + .unwrap(); + fixture.no_launch(); + assert_eq!( + agents::load_managed_agents(fixture.app.handle()).unwrap()[0] + .last_error + .as_deref(), + Some("fixture peer offline") + ); +} + +#[tokio::test] +async fn bulk_restart_preflights_every_captured_community_and_revalidates_before_spawn() { + let _guard = agents::lock_path_mutex_async().await; + // The unchanged case proves a real lazy pair spawn and receipts. The other + // cases edit while the first provider awaits, after BOTH plans were captured. + for mutation in ["unchanged", "revision", "workspace", "selection"] { + let mut fixture = Fixture::new(); + let first = fixture.named(ONE, "relay-mesh", "mesh-one"); + let second = fixture.named(TWO, "relay-mesh", "mesh-two"); + let workspace = fixture.temp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let mut entry = second.clone(); + entry.workspace = Some(workspace.display().to_string()); + let second = save(&mut fixture.record, &fixture.owner, TWO, entry); + fixture.record.last_stopped_at = Some("prior security restart Stop".into()); + fixture.persist(); + let calls = RefCell::new(Vec::new()); + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let relays = vec![ONE.into(), TWO.into()]; + let future = crate::commands::start_local_agent_pairs_with_preflight_using( + &app, + &state, + &pubkey, + &relays, + |model, _| { + calls.borrow_mut().push(model); + let wait = wait.borrow_mut().take(); + async move { + if let Some(wait) = wait { + wait.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + match mutation { + "revision" => { + let mut edit = second.clone(); + edit.model = "unpreflighted-edit".into(); + save(&mut fixture.record, &fixture.owner, TWO, edit); + fixture.persist(); + } + "workspace" => std::fs::remove_dir(&workspace).unwrap(), + "selection" => { + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + TWO, + &second.host, + RuntimeConfigurations { + selected: None, + entries: vec![second.clone()], + }, + ) + .unwrap(); + fixture.persist(); + } + _ => {} + } + resume.send(()).unwrap(); + let result = future.await; + assert_eq!( + *calls.borrow(), + vec![Some("mesh-one".into()), Some("mesh-two".into())] + ); + let mut launches = fixture.finish_children(); + launches.sort_by(|a, b| a.0.cmp(&b.0)); + if mutation == "unchanged" { + result.unwrap(); + assert_eq!( + launches, + vec![ + (ONE.into(), Some(first.reference())), + (TWO.into(), Some(second.reference())) + ] + ); + } else { + assert!(result.is_err()); + assert_eq!(launches, vec![(ONE.into(), Some(first.reference()))]); + } + let output = std::fs::read_to_string(fixture.temp.path().join("launches")).unwrap(); + assert!(output.lines().all(|line| line.ends_with("|true"))); + assert!(!output.contains("unpreflighted-edit")); + assert_eq!( + agents::read_all_agent_runtime_receipts(fixture.app.handle()).len(), + launches.len() + ); + } +} + +#[tokio::test] +async fn restore_selection_fence_survives_suspension_in_both_directions() { + let _guard = agents::lock_path_mutex_async().await; + for starts_named in [false, true] { + let mut fixture = Fixture::new(); + let named = fixture.named(ONE, "relay-mesh", "named-model"); + if !starts_named { + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + ONE, + &named.host, + RuntimeConfigurations { + selected: None, + entries: vec![named.clone()], + }, + ) + .unwrap(); + } + fixture.persist(); + let app = fixture.app.handle().clone(); + let shutdown = AtomicBool::new(false); + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let future = agents::restore_with( + &app, + &shutdown, + |model, _| { + assert_eq!(model.as_deref(), starts_named.then_some("named-model")); + let wait = wait.borrow_mut().take().unwrap(); + async move { + wait.await.unwrap(); + Ok(()) + } + }, + |_| {}, + |_, _| panic!("stale restore must not publish"), + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture + .record + .runtime_configurations + .replace( + &fixture.owner, + ONE, + &named.host, + RuntimeConfigurations { + selected: (!starts_named).then(|| named.id.clone()), + entries: vec![named.clone()], + }, + ) + .unwrap(); + fixture.persist(); + resume.send(()).unwrap(); + future.await.unwrap(); + fixture.no_launch(); + assert!(agents::load_managed_agents(&app).unwrap()[0] + .last_error + .as_deref() + .unwrap() + .contains("Selected configuration changed")); + } +} + +#[tokio::test] +async fn ordinary_default_revalidates_effective_inputs_not_only_selection() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let pubkey = fixture.record.pubkey.clone(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = crate::commands::start_local_agent_with_preflight_using( + &app, + &state, + &pubkey, + crate::commands::LocalStartIntent::Automatic, + None, + None, + None, + None, + |model, _| async move { + assert_eq!(model, None); + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + fixture.record.provider = Some("relay-mesh".into()); + fixture.record.model = Some("unpreflighted-default-edit".into()); + fixture.persist(); + resume.send(()).unwrap(); + assert!(future + .await + .unwrap_err() + .contains("Agent changed during runtime preflight")); + fixture.no_launch(); +} + +impl Fixture { + fn hold_children(&self) { + let path = self.temp.path().join("buzz-acp"); + let mut script = std::fs::read_to_string(&path).unwrap(); + // exec means fixture cleanup owns the only remaining PID; no orphan sleep. + script.push_str("exec /bin/sleep 30\n"); + std::fs::write(path, script).unwrap(); + } + fn running(&self, relay: &str) -> (u32, String, Option) { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&self.record.pubkey, relay).unwrap(); + let runtime = runtimes.get_mut(&key).unwrap(); + assert!(runtime.child.try_wait().unwrap().is_none()); + ( + runtime.child.id(), + runtime.start_nonce.clone(), + runtime.spawn_config.runtime_configuration.clone(), + ) + } +} + +#[tokio::test] +async fn direct_restart_preflights_target_and_preserves_old_child_on_refusal() { + let _guard = agents::lock_path_mutex_async().await; + for relay in [ONE, TWO] { + for mutation in [ + "unchanged", + "revision", + "workspace", + "offline", + "generation", + ] { + let mut fixture = Fixture::new(); + fixture.hold_children(); + let old = fixture.named(relay, "openai", "old-model"); + fixture.persist(); + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + true, + false, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model, None); + Ok(()) + }, + ) + .await + .unwrap(); + let old_runtime = fixture.running(relay); + assert_eq!(old_runtime.2, Some(old.reference())); + let workspace = fixture.temp.path().join("target-workspace"); + std::fs::create_dir(&workspace).unwrap(); + let mut target = fixture.named(relay, "relay-mesh", "target-model"); + target.workspace = Some(workspace.display().to_string()); + let target = save(&mut fixture.record, &fixture.owner, relay, target); + fixture.persist(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + true, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model.as_deref(), Some("target-model")); + wait.await.unwrap(); + if mutation == "offline" { + Err("fixture provider unavailable".into()) + } else { + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + assert_eq!(fixture.running(relay), old_runtime); + let mut replacement = None; + match mutation { + "revision" => { + let mut edited = target.clone(); + edited.model = "not-preflighted".into(); + save(&mut fixture.record, &fixture.owner, relay, edited); + fixture.persist(); + } + "workspace" => std::fs::remove_dir(&workspace).unwrap(), + "generation" => { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + true, + fixture.app.handle().clone(), + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + replacement = Some(fixture.running(relay)); + } + + _ => {} + } + resume.send(()).unwrap(); + let result = future.await; + if mutation == "unchanged" { + result.unwrap(); + let new_runtime = fixture.running(relay); + assert_ne!(new_runtime.1, old_runtime.1); + assert_eq!(new_runtime.2, Some(target.reference())); + } else { + assert!(result.is_err()); + assert_eq!(fixture.running(relay), replacement.unwrap_or(old_runtime)); + } + } + } +} + +#[tokio::test] +async fn direct_start_and_reconcile_continuation_refuse_selection_change_and_stop() { + let _guard = agents::lock_path_mutex_async().await; + for explicit in [true, false] { + for mutation in ["selection", "stop"] { + let mut fixture = Fixture::new(); + fixture.persist(); + let (resume, wait) = tokio::sync::oneshot::channel(); + let future = agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + ONE.into(), + None, + explicit, + false, + fixture.app.handle().clone(), + |model, _| async move { + assert_eq!(model, None); + wait.await.unwrap(); + Ok(()) + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + if mutation == "selection" { + fixture.named(ONE, "relay-mesh", "not-preflighted"); + fixture.persist(); + } else { + let state = fixture.app.state::(); + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + agents::stop_pair_locked( + fixture.record.pubkey.clone(), + ONE.into(), + fixture.app.handle().clone(), + ) + .unwrap(); + } + resume.send(()).unwrap(); + assert!(future.await.is_err()); + fixture.no_launch(); + } + } +} + +#[cfg(feature = "mesh-llm")] +#[tokio::test] +async fn recovery_uses_running_pair_snapshots_not_default_or_next_selection() { + let _guard = agents::lock_path_mutex_async().await; + for default_mesh in [false, true] { + let mut fixture = Fixture::new(); + fixture.hold_children(); + fixture.record.provider = Some(if default_mesh { "relay-mesh" } else { "openai" }.into()); + fixture.named(ONE, "relay-mesh", "running-one"); + fixture.named( + TWO, + if default_mesh { "openai" } else { "relay-mesh" }, + "running-two", + ); + fixture.persist(); + for relay in [ONE, TWO] { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + None, + false, + false, + fixture.app.handle().clone(), + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + } + let app = fixture.app.handle().clone(); + let state = app.state::(); + let mut before = crate::mesh_llm::running_mesh_consumers(&state); + before.sort_by(|a, b| a.0.relay_url.cmp(&b.0.relay_url)); + let expected = if default_mesh { + vec![(ONE, "running-one")] + } else { + vec![(ONE, "running-one"), (TWO, "running-two")] + }; + assert_eq!( + before + .iter() + .map(|(key, _, model)| (key.relay_url.as_str(), model.as_str())) + .collect::>(), + expected + ); + // Both record Default and both next-launch selections change. Neither + // can rewrite the provider/model requirement of a running generation. + fixture.record.provider = Some("openai".into()); + fixture.named(ONE, "openai", "next-one"); + fixture.named(TWO, "relay-mesh", "next-two"); + fixture.persist(); + let mut after = crate::mesh_llm::running_mesh_consumers(&state); + after.sort_by(|a, b| a.0.relay_url.cmp(&b.0.relay_url)); + assert_eq!(before, after); + } +} + +#[tokio::test] +async fn reconcile_keeps_authorization_inputs_without_cross_pair_timestamp_failure() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + fixture.hold_children(); + fixture.named(ONE, "relay-mesh", "mesh-one"); + fixture.named(TWO, "relay-mesh", "mesh-two"); + fixture.record.last_stopped_at = Some("prior-stop".into()); + fixture.persist(); + let probed_record = fixture.record.clone(); + for (relay, model) in [(ONE, "mesh-one"), (TWO, "mesh-two")] { + agents::start_pair_with_preflight( + fixture.record.pubkey.clone(), + relay.into(), + Some(&probed_record), + false, + false, + fixture.app.handle().clone(), + |actual, _| async move { + assert_eq!(actual.as_deref(), Some(model)); + Ok(()) + }, + ) + .await + .unwrap(); + } + assert_ne!(fixture.running(ONE).1, fixture.running(TWO).1); +} + +#[tokio::test] +async fn bulk_stop_during_preflight_cannot_be_resumed_by_automatic_start() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + fixture.named(ONE, "relay-mesh", "mesh-one"); + fixture.named(TWO, "relay-mesh", "mesh-two"); + fixture.persist(); + let app = fixture.app.handle().clone(); + let state = app.state::(); + let relays = vec![ONE.into(), TWO.into()]; + let (resume, wait) = tokio::sync::oneshot::channel(); + let wait = RefCell::new(Some(wait)); + let future = crate::commands::start_local_agent_pairs_with_preflight_using( + &app, + &state, + &fixture.record.pubkey, + &relays, + |_, _| { + let wait = wait.borrow_mut().take(); + async move { + if let Some(wait) = wait { + wait.await.unwrap(); + } + Ok(()) + } + }, + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + { + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + agents::stop_pair_locked(fixture.record.pubkey.clone(), TWO.into(), app.clone()).unwrap(); + } + resume.send(()).unwrap(); + assert!(future.await.unwrap_err().contains("Stop interrupted")); + fixture.no_launch(); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs new file mode 100644 index 00000000000..3e41f73f32b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/store.rs @@ -0,0 +1,75 @@ +//! Durable scope is owner + community, independent of the global agent identity. +use super::*; + +/// Only the scoped sets are persisted. The resolved launch is an immutable native projection. +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfigurationStore { + scopes: BTreeMap>, + #[serde(skip)] + pub(super) launch: Option, +} + +impl RuntimeConfigurationStore { + pub(crate) fn get(&self, owner: &str, community: &str) -> RuntimeConfigurations { + self.scopes + .get(owner) + .and_then(|communities| communities.get(community)) + .cloned() + .unwrap_or_default() + } + + /// Replace one authorized scope atomically; callers persist the whole agent once. + pub(crate) fn replace( + &mut self, + owner: &str, + community: &str, + host: &str, + mut next: RuntimeConfigurations, + ) -> Result<(), String> { + next.validate()?; + let previous = self.get(owner, community); + // A local editor may preserve, but neither invent, modify nor delete another host's entries. + for entry in previous.entries.iter().filter(|entry| entry.host != host) { + if !next.entries.contains(entry) { + return Err("Another Desktop's configuration cannot be changed here".into()); + } + } + for entry in &mut next.entries { + let old = previous.entries.iter().find(|old| old.id == entry.id); + if entry.host != host && old != Some(entry) { + return Err("Another Desktop's configuration cannot be changed here".into()); + } + if old.is_some_and(|old| old.host != entry.host) { + return Err("A configuration's Desktop cannot be changed".into()); + } + if old != Some(entry) { + entry.revision = uuid::Uuid::new_v4().to_string(); + } + } + if next.selected()?.is_some_and(|entry| entry.host != host) { + return Err("Select a configuration on this Desktop".into()); + } + self.scopes + .entry(owner.into()) + .or_default() + .insert(community.into(), next); + Ok(()) + } +} + +/// Capture selection at the actual launch pair, never the record's creation community. +pub(crate) fn selected_reference( + record: &ManagedAgentRecord, + owner: Option<&str>, + community: &str, +) -> Result, String> { + let Some(owner) = owner else { + return Ok(None); + }; + record + .runtime_configurations + .get(owner, community) + .selected() + .map(|entry| entry.map(RuntimeConfiguration::reference)) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs new file mode 100644 index 00000000000..b7fbee2e4e6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -0,0 +1,515 @@ +use super::*; +use crate::managed_agents as agents; + +fn record() -> ManagedAgentRecord { + let mut record = + agents::runtime::test_fixtures::fixture(agents::RespondTo::OwnerOnly, vec![], None); + let keys = nostr::Keys::generate(); + record.pubkey = keys.public_key().to_hex(); + record.private_key_nsec = keys.secret_key().to_secret_hex(); + record +} + +fn attest(record: &mut ManagedAgentRecord, owner: &nostr::Keys) -> String { + record.auth_tag = Some( + buzz_sdk_pkg::nip_oa::compute_auth_tag( + owner, + &nostr::PublicKey::from_hex(&record.pubkey).unwrap(), + "", + ) + .unwrap(), + ); + owner.public_key().to_hex() +} + +fn config(host: &str) -> RuntimeConfiguration { + RuntimeConfiguration { + id: uuid::Uuid::new_v4().to_string(), + revision: uuid::Uuid::new_v4().to_string(), + name: "Focused".into(), + host: host.into(), + runtime: "buzz-agent".into(), + model: "fixture-model".into(), + provider: Some("openai".into()), + workspace: None, + credential_refs: BTreeMap::new(), + } +} + +fn save( + record: &mut ManagedAgentRecord, + owner: &str, + community: &str, + entry: RuntimeConfiguration, +) -> RuntimeConfiguration { + let host = entry.host.clone(); + record + .runtime_configurations + .replace( + owner, + community, + &host, + RuntimeConfigurations { + selected: Some(entry.id.clone()), + entries: vec![entry], + }, + ) + .unwrap(); + record + .runtime_configurations + .get(owner, community) + .entries + .remove(0) +} + +#[test] +fn migration_keeps_default_and_launch_projection_is_not_persisted() { + let original = record(); + let mut json = serde_json::to_value(&original).unwrap(); + json.as_object_mut() + .unwrap() + .remove("runtime_configurations"); + let mut migrated: ManagedAgentRecord = serde_json::from_value(json).unwrap(); + assert!(selected_reference(&migrated, Some("owner"), "community") + .unwrap() + .is_none()); + migrated.runtime_configurations.launch = Some(config("host")); + let resolved = + agents::effective_config::resolve_effective_config(&migrated, &[], &Default::default()) + .require_resolved() + .unwrap(); + assert_eq!(resolved.model.value.as_deref(), Some("fixture-model")); + assert_eq!( + resolved.model.source, + agents::effective_config::ConfigSource::RuntimeConfiguration + ); + let restored: ManagedAgentRecord = + serde_json::from_value(serde_json::to_value(&migrated).unwrap()).unwrap(); + assert!(selected(&restored).unwrap().is_none()); + assert_eq!(restored.pubkey, original.pubkey); + assert_eq!(restored.private_key_nsec, original.private_key_nsec); +} + +#[test] +fn agent_in_two_communities_preserves_private_sets_and_rejects_foreign_references() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + let first = save(&mut record, &owner, "one", config("host-one")); + let second = save(&mut record, &owner, "two", config("host-two")); + let saved_two = record.runtime_configurations.get(&owner, "two"); + let mut changed = first.clone(); + changed.name = "Edited in one".into(); + let changed = save(&mut record, &owner, "one", changed); + assert_ne!(changed.revision, first.revision); + assert_eq!(record.runtime_configurations.get(&owner, "two"), saved_two); + assert_eq!( + selected_reference(&record, Some(&owner), "two").unwrap(), + Some(second.reference()) + ); + let view = record.runtime_configurations.get(&owner, "one"); + assert!(!serde_json::to_string(&view).unwrap().contains(&second.id)); + assert!(!serde_json::to_string(&catalog( + &record, + &[], + &Default::default(), + "host-one", + &owner, + "one" + )) + .unwrap() + .contains(&second.id)); + assert!(record + .runtime_configurations + .get("other-owner", "one") + .entries + .is_empty()); + for (reference, host, requested_owner, community) in [ + (&second, "host-two", owner.as_str(), "one"), + (&first, "host-two", owner.as_str(), "one"), + (&changed, "host-one", "other-owner", "one"), + ] { + assert!(prepare( + &record, + Some(&reference.reference()), + &[], + &Default::default(), + host, + requested_owner, + community + ) + .is_err()); + } + assert!(catalog( + &record, + &[], + &Default::default(), + "host-one", + "other-owner", + "one" + ) + .iter() + .all(|c| !c.eligible)); + let mut bad_selection = view.clone(); + bad_selection.selected = Some(second.id); + assert!(record + .runtime_configurations + .replace(&owner, "one", "host-one", bad_selection) + .is_err()); + assert_eq!(record.runtime_configurations.get(&owner, "one"), view); +} + +#[test] +fn other_host_entries_are_preserved_not_a_global_validation_failure() { + let mut record = record(); + let first = save(&mut record, "owner", "one", config("host-one")); + let mut set = record.runtime_configurations.get("owner", "one"); + let second = config("host-two"); + set.entries.push(second.clone()); + set.selected = None; + record + .runtime_configurations + .replace("owner", "one", "host-two", set) + .unwrap(); + let mut set = record.runtime_configurations.get("owner", "one"); + set.selected = Some(first.id.clone()); + record + .runtime_configurations + .replace("owner", "one", "host-one", set.clone()) + .unwrap(); + set.selected = Some(second.id.clone()); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); + let mut set = record.runtime_configurations.get("owner", "one"); + set.entries.retain(|c| c.id == first.id); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); + let mut set = record.runtime_configurations.get("owner", "one"); + set.entries[1].model = "tamper".into(); + assert!(record + .runtime_configurations + .replace("owner", "one", "host-one", set) + .is_err()); +} + +#[test] +fn malformed_or_stale_reference_is_rejected_before_launch_resolution() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + let mut named = save(&mut record, &owner, "one", config("host")); + named.revision = uuid::Uuid::new_v4().to_string(); + assert!(prepare( + &record, + Some(&named.reference()), + &[], + &Default::default(), + "host", + &owner, + "one" + ) + .is_err()); + named + .credential_refs + .insert("BUZZ_PRIVATE_KEY".into(), "SECRET".into()); + assert!(RuntimeConfigurations { + selected: None, + entries: vec![named] + } + .validate() + .is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() { + use std::os::unix::fs::PermissionsExt; + use tauri::Manager; + let _guard = agents::lock_path_mutex_async().await; + let temp = tempfile::tempdir().unwrap(); + struct Restore(Vec<(&'static str, Option)>); + impl Drop for Restore { + fn drop(&mut self) { + for (key, value) in self.0.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + agents::clear_resolve_cache(); + } + } + let _restore = Restore( + ["HOME", "XDG_DATA_HOME", "PATH"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(), + ); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", temp.path().display())); + agents::clear_resolve_cache(); + let capture = temp.path().join("capture"); + // A fixture child at the real spawn boundary, not an ACP/session or live-model claim. + for (name, script) in [("buzz-agent", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-dev-mcp", "#!/bin/sh\nexit 0\n".to_string()), ("buzz-acp", format!("#!/bin/sh\nprintf '%s\\n' \"$BUZZ_ACP_MODEL\" \"$BUZZ_AGENT_MODEL\" \"$BUZZ_AGENT_PROVIDER\" \"$BUZZ_ACP_REQUIRED_MODEL\" \"$PWD\" \"$BUZZ_ACP_TEAM_INSTRUCTIONS\" \"$BUZZ_ACP_SESSION_POLICY\" \"$BUZZ_ACP_MCP_COMMAND\" \"$BUZZ_ACP_REQUIRE_MODEL\" > '{}'\n", capture.display()))] { + let path = temp.path().join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let owner = app + .state::() + .signing_keys() + .unwrap() + .public_key() + .to_hex(); + let community = "wss://runtime-fixture.example"; + let mut record = record(); + attest( + &mut record, + &app.state::() + .signing_keys() + .unwrap(), + ); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.env_vars.insert( + "OPENAI_COMPAT_API_KEY".into(), + "fixture-not-a-secret".into(), + ); + // Resolve the app-scoped host independently of a selected named configuration. + let base = prepare_for_app(&app.handle().clone(), &record, None, &owner, community); + // Default may lack a provider: obtain the same host identity used by prepare_for_app. + drop(base); + let scope = agents::retention::RetentionScope { + db_path: agents::retention::scoped_retention_db_path( + &agents::managed_agents_base_dir(app.handle()).unwrap(), + community, + &owner, + ), + relay_url: community.into(), + owner_keys: app + .state::() + .signing_keys() + .unwrap(), + }; + let host = crate::commands::desktop_stop::local_id( + &mut agents::retention::open_retention_db(&scope.db_path).unwrap(), + &scope, + ) + .unwrap(); + let mut named = config(&host); + named.workspace = Some(temp.path().display().to_string()); + let named = save(&mut record, &owner, community, named); + let mut teams = agents::load_teams(app.handle()).unwrap(); + teams[0].instructions = Some("prepared team instructions".into()); + record.team_id = Some(teams[0].id.clone()); + agents::save_teams(app.handle(), &teams).unwrap(); + for (key, value) in [ + ("BUZZ_ACP_MODEL", "wrong-inherited-model"), + ("BUZZ_ACP_REQUIRE_MODEL", "false"), + ("BUZZ_ACP_REQUIRED_MODEL", "wrong-inherited-model"), + ("BUZZ_ACP_MCP_COMMAND", "wrong-inherited-tool"), + ] { + record.env_vars.insert(key.into(), value.into()); + } + let mut plan = prepare_for_app( + app.handle(), + &record, + Some(&named.reference()), + &owner, + community, + ) + .unwrap(); + assert!(plan.require_preflight().is_err()); + let unpreflighted = agents::spawn_agent_child_prepared( + app.handle(), + &record, + community, + true, + Some(&owner), + None, + None, + Some(&plan), + ) + .unwrap_err(); + assert!(unpreflighted.contains("has not completed provider preflight")); + preflight_with(&mut plan, &owner, community, false, |_, _| async { Ok(()) }) + .await + .unwrap(); + // Team content and session partitioning changed while preflight was awaiting. + // This launch uses the prepared values; a later preparation sees the edits. + teams[0].instructions = Some("next launch instructions".into()); + agents::save_teams(app.handle(), &teams).unwrap(); + app.state::() + .thread_scoped_acp_sessions_enabled() + .store(true, std::sync::atomic::Ordering::Release); + assert_eq!( + selected_reference(&record, Some(&owner), community).unwrap(), + Some(named.reference()) + ); + assert_eq!(plan.configuration(), Some(named.reference())); + assert!(plan + .check_scope(Some(&owner), "wss://other.example") + .is_err()); + let mut edited = record.clone(); + let mut changed = named.clone(); + changed.model = "other-model".into(); + save(&mut edited, &owner, community, changed); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.private_key_nsec.clear(); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.parallelism += 1; + assert!(plan.revalidate(&edited, &[], &Default::default()).is_err()); + edited = record.clone(); + edited.updated_at = "stopped".into(); + edited.last_stopped_at = Some("stopped".into()); + assert!(plan.revalidate(&edited, &[], &Default::default()).is_ok()); + let safe = serde_json::to_string(&catalog( + &record, + &[], + &Default::default(), + &host, + &owner, + community, + )) + .unwrap(); + assert!(!safe.contains("credentialRefs")); + assert!(!safe.contains("workspace")); + assert!(!safe.contains("fixture-not-a-secret")); + let next = prepare_for_app( + app.handle(), + &record, + Some(&named.reference()), + &owner, + community, + ) + .unwrap(); + assert_eq!( + next.app_inputs, + Some(( + Some("next launch instructions".into()), + agents::AcpSessionPolicy::Thread + )) + ); + // A cached resolution must not hide a tool removed after preparation. + let mcp_path = required_mcp_command("buzz-agent").unwrap().unwrap(); + assert_eq!(mcp_path, temp.path().join("buzz-dev-mcp")); + std::fs::set_permissions(&mcp_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(plan.revalidate(&record, &[], &Default::default()).is_err()); + assert!( + !catalog(&record, &[], &Default::default(), &host, &owner, community) + .iter() + .find(|entry| entry.configuration == Some(named.reference())) + .unwrap() + .eligible + ); + assert!(required_mcp_command("claude-agent-acp").unwrap().is_none()); + std::fs::set_permissions(&mcp_path, std::fs::Permissions::from_mode(0o700)).unwrap(); + let relay = crate::relay::bind_expected_relay_scope(None, community.into()).unwrap(); + let mut runtimes = std::collections::HashMap::new(); + let path = agents::storage::managed_agents_store_path(app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted( + &path, + &serde_json::to_vec(&[record.clone()]).unwrap(), + ) + .unwrap(); + agents::start_managed_agent_process_prepared( + app.handle(), + &mut record, + &mut runtimes, + Some(&owner), + &relay, + None, + None, + Some(&plan), + ) + .unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&record.pubkey, community).unwrap(); + let mut running = runtimes.remove(&key).unwrap(); + assert!(running.child.wait().unwrap().success()); + assert_eq!( + running.spawn_config.runtime_configuration, + Some(named.reference()) + ); + assert_eq!( + std::fs::read_to_string(capture).unwrap(), + format!( + "fixture-model\nfixture-model\nopenai\nfixture-model\n{}\nprepared team instructions\nchannel\n{}\n\n", + // The shell reports physical PWD (/private/var on macOS), not + // the symlink spelling returned by the temporary directory API. + temp.path().canonicalize().unwrap().display(), + mcp_path.display() + ) + ); + assert!(agents::read_all_agent_runtime_receipts(app.handle()) + .iter() + .any(|(_, receipt)| receipt.runtime_configuration == Some(named.reference()))); + agents::remove_agent_runtime_receipt(app.handle(), &key); +} + +#[test] +fn final_model_authority_clears_inheritance_and_preserves_claude_a1() { + use std::ffi::OsStr; + for required in [None, Some("exact-wire-model")] { + let mut command = std::process::Command::new("fixture"); + command.env("BUZZ_ACP_REQUIRE_MODEL", "false"); + command.env("BUZZ_ACP_REQUIRED_MODEL", "wrong-inherited-model"); + command.env("ANTHROPIC_MODEL", "exact-wire-model"); + command.env_remove("BUZZ_ACP_MODEL"); + apply_required_model_env(&mut command, required).unwrap(); + let env = command.get_envs().collect::>(); + assert_eq!(env[OsStr::new("BUZZ_ACP_REQUIRE_MODEL")], None); + assert_eq!( + env[OsStr::new("BUZZ_ACP_REQUIRED_MODEL")], + required.map(OsStr::new) + ); + assert_eq!(env[OsStr::new("BUZZ_ACP_MODEL")], None); + assert_eq!( + env[OsStr::new("ANTHROPIC_MODEL")], + Some(OsStr::new("exact-wire-model")) + ); + } + for model in ["", " "] { + assert!( + apply_required_model_env(&mut std::process::Command::new("fixture"), Some(model)) + .is_err() + ); + } +} + +#[test] +fn ordinary_selection_fence_includes_default() { + let mut record = record(); + let owner = attest(&mut record, &nostr::Keys::generate()); + assert!(check_selection(&record, Some(&owner), "one", None).is_ok()); + let named = save(&mut record, &owner, "one", config("host")); + assert!(check_selection(&record, Some(&owner), "one", None).is_err()); + assert!(check_selection(&record, Some(&owner), "one", Some(&named.reference())).is_ok()); + assert!(check_selection(&record, Some(&owner), "two", None).is_ok()); + record + .runtime_configurations + .replace( + &owner, + "one", + "host", + RuntimeConfigurations { + selected: None, + entries: vec![named.clone()], + }, + ) + .unwrap(); + assert!(check_selection(&record, Some(&owner), "one", Some(&named.reference())).is_err()); +} + +#[cfg(unix)] +#[path = "orchestration_tests.rs"] +mod orchestration; + +#[cfg(all(unix, not(feature = "system-keyring")))] +mod remote_credentials; diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs new file mode 100644 index 00000000000..be4e76cdd27 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs @@ -0,0 +1,423 @@ +//! F6 production lifecycle boundaries, not standalone cache invalidation tests. +//! Parent fixture owns only synthetic identities, temporary files and fake children. +use super::*; +use std::sync::{Arc, Mutex}; + +fn write_raw(fixture: &Fixture, records: &[ManagedAgentRecord]) { + let path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted(&path, &serde_json::to_vec(records).unwrap()) + .unwrap(); +} + +fn raw(fixture: &Fixture) -> ManagedAgentRecord { + agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == fixture.record.pubkey) + .unwrap() +} + +struct Synthetic { + backend: Arc>>, + store: &'static crate::secret_store::SecretStore, + name: String, + _guard: agents::storage::TestAgentSecretStore, +} +impl Synthetic { + fn warm(fixture: &Fixture) -> Self { + let name = format!("agent:{}", fixture.record.pubkey); + let backend = Arc::new(Mutex::new(std::collections::HashMap::from([ + (name.clone(), fixture.record.private_key_nsec.clone()), + ("identity".into(), "unrelated-synthetic-identity".into()), + ("agent:other".into(), "rotated-synthetic-key".into()), + ]))); + // Bounded to this fixture suite; the override requires a static store, + // and is always removed by its guard before another fixture runs. + let store = Box::leak(Box::new(crate::secret_store::SecretStore::synthetic( + backend.clone(), + ))); + assert_eq!( + store.load(&name).unwrap(), + Some(fixture.record.private_key_nsec.clone()) + ); + let mut record = raw(fixture); + record.private_key_nsec.clear(); + write_raw(fixture, &[record]); + Self { + backend, + store, + name, + _guard: agents::storage::TestAgentSecretStore::install(store), + } + } + fn revoke(&self) { + self.backend.lock().unwrap().remove(&self.name); + // A fresh read MUST NOT be mistaken for cache invalidation as the fix. + assert!(self + .store + .load_fresh_readonly(&self.name) + .unwrap() + .is_none()); + assert!(self.store.load(&self.name).unwrap().is_some()); + } + fn assert_absent(&self, fixture: &Fixture) { + let map = self.backend.lock().unwrap(); + assert!(!map.contains_key(&self.name)); + assert_eq!(map.get("identity").unwrap(), "unrelated-synthetic-identity"); + assert_eq!(map.get("agent:other").unwrap(), "rotated-synthetic-key"); + assert!(raw(fixture).private_key_nsec.is_empty()); + } +} + +// Acquire the process-environment lock outside the executor: no synchronous +// mutex acquisition can block a competing async fixture's executor thread. +fn with_path_lock(test: impl std::future::Future) { + let _guard = agents::lock_path_mutex(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(test); +} + +async fn assert_ineligible(fixture: &Fixture) { + let catalog = fixture.action(Action::Catalog, None).await; + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + assert!(fixture.running().is_none()); +} + +#[test] +fn warm_external_revocation_survives_ordinary_signed_stop() { + with_path_lock(async { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + backend.revoke(); + let catalog = fixture.action(Action::Catalog, None).await; + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!(fixture.running().unwrap().0, pid); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(!agents::process_is_running(pid)); + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + backend.assert_absent(&fixture); + }); +} + +#[test] +fn inline_revocation_during_ordinary_stop_survives_same_host_start() { + with_path_lock(async { + for launch in ["signed-stop", "local-start", "restore"] { + let mut fixture = Fixture::new(); + fixture.record.start_on_app_launch = true; + fixture.persist(); + let path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + let revoked_path = fixture.temp.path().join("ordinary-stop-revoked.json"); + let mut revoked = raw(&fixture); + revoked.private_key_nsec.clear(); + std::fs::write(&revoked_path, serde_json::to_vec(&[revoked]).unwrap()).unwrap(); + // System shell/sleep environments are not observable through the + // macOS ownership reader. Exec our existing marked child seam so + // the prior-session receipt is genuinely eligible for teardown. + let ready = fixture.temp.path().join("revoking-child-ready"); + let script = std::fs::read_to_string(&fixture.record.acp_command).unwrap(); + let script = script.replace("exec /bin/sleep 20", &format!( + "export BUZZ_TEST_MARKED_CHILD_FIXTURE=1\nexport BUZZ_TEST_MARKED_CHILD_READY='{}'\nexport BUZZ_TEST_MARKED_CHILD_REVOKED_STORE='{}'\nexport BUZZ_TEST_MARKED_CHILD_STORE='{}'\nexec '{}' --exact managed_agents::runtime::test_fixtures::marked_child_process_fixture --nocapture", + ready.display(), revoked_path.display(), path.display(), + std::env::current_exe().unwrap().display(), + )); + std::fs::write(&fixture.record.acp_command, script).unwrap(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + let _child_guard = agents::runtime::test_fixtures::MarkedProcessGuard::new(pid); + for _ in 0..100 { + if ready.is_file() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!( + ready.is_file(), + "TERM handler must be ready before teardown" + ); + let receipts = agents::read_all_agent_runtime_receipts(fixture.app.handle()); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].1.pid, pid); + assert!( + agents::valid_agent_runtime_receipt( + &receipts[0].0, + &receipts[0].1, + &agents::current_instance_id(fixture.app.handle()), + ), + "synthetic prior-session child must prove production ownership" + ); + if launch == "signed-stop" { + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + } else { + // Simulate a previous Desktop session: keep only the durable receipt. + // Teardown now happens INSIDE ordinary local Start / restore, after + // preflight has captured the still-valid inline credential. + let state = fixture.app.state::(); + let key = + agents::ManagedAgentRuntimeKey::new(&fixture.record.pubkey, COMMUNITY).unwrap(); + let mut old_runtime = state + .managed_agent_processes + .lock() + .unwrap() + .remove(&key) + .unwrap(); + // A previous Desktop's child is reaped by its new parent. Keep + // that behavior here: retaining an unreaped Child would turn a + // successful TERM into a zombie and falsely fail teardown. + let reaper = std::thread::spawn(move || old_runtime.child.wait().unwrap()); + let refused = if launch == "local-start" { + crate::commands::start_local_agent_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + crate::commands::LocalStartIntent::Explicit, + Some(COMMUNITY), + Some(&fixture.owner.public_key().to_hex()), + None, + Some(Some(&fixture.named.reference())), + |_, _| async { Ok(()) }, + ) + .await + .is_err() + } else { + agents::restore_with( + fixture.app.handle(), + &std::sync::atomic::AtomicBool::new(false), + |_, _| async { Ok(()) }, + |_| {}, + |_, _| panic!("post-teardown revoked restore must not publish"), + ) + .await + .unwrap(); + true + }; + // Observe the actual boundary BEFORE cleanup. Refusal due only + // to unsuccessful teardown must not count as key revocation. + let exited = !agents::process_is_running(pid); + let revoked = raw(&fixture).private_key_nsec.is_empty(); + let _ = agents::terminate_process(pid); + assert!(reaper.join().unwrap().success(), "TERM effect must finish"); + assert!(exited, "ordinary teardown must reap the prior child"); + assert!(revoked, "revocation must precede fresh admission"); + assert!(refused, "post-teardown revoked local Start must refuse"); + } + assert!(!agents::process_is_running(pid)); + assert!(raw(&fixture).private_key_nsec.is_empty()); + assert_ineligible(&fixture).await; + assert!(raw(&fixture).private_key_nsec.is_empty()); + assert_eq!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .lines() + .count(), + 1 + ); + } + }); +} + +#[test] +fn ordinary_local_named_start_cannot_provision_from_warm_cache() { + with_path_lock(async { + for before_preparation in [true, false] { + for preflight_succeeds in [true, false] { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + if before_preparation { + backend.revoke(); + } + let state = fixture.app.state::(); + let result = crate::commands::start_local_agent_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + crate::commands::LocalStartIntent::Explicit, + Some(COMMUNITY), + Some(&fixture.owner.public_key().to_hex()), + None, + Some(Some(&fixture.named.reference())), + |_, _| { + if !before_preparation { + backend.revoke(); + } + backend.assert_absent(&fixture); + async move { + if preflight_succeeds { + Ok(()) + } else { + Err("synthetic provider refusal".into()) + } + } + }, + ) + .await; + assert!(result.is_err()); + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + assert!(!fixture.temp.path().join("launches").exists()); + } + } + }); +} + +#[test] +fn bulk_and_restore_preparation_never_restore_cached_credentials() { + with_path_lock(async { + for restore in [false, true] { + let mut fixture = Fixture::new(); + fixture.record.start_on_app_launch = true; + fixture.persist(); + let backend = Synthetic::warm(&fixture); + if restore { + let shutdown = std::sync::atomic::AtomicBool::new(false); + agents::restore_with( + fixture.app.handle(), + &shutdown, + |_, _| { + backend.revoke(); + async { Ok(()) } + }, + |_| {}, + |_, _| panic!("revoked restore must not publish"), + ) + .await + .unwrap(); + } else { + let state = fixture.app.state::(); + let result = crate::commands::start_local_agent_pairs_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + &[COMMUNITY.into()], + |_, _| { + backend.revoke(); + async { Ok(()) } + }, + ) + .await; + assert!(result.is_err()); + } + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + assert!(!fixture.temp.path().join("launches").exists()); + } + }); +} + +#[test] +fn metadata_merge_preserves_rotations_scopes_and_removed_records() { + with_path_lock(async { + let fixture = Fixture::new(); + let stale = raw(&fixture); + let mut current = stale.clone(); + current.private_key_nsec = "deliberately-rotated-inline-fixture".into(); + current.name = "newer definition".into(); + save( + &mut current, + &fixture.owner.public_key().to_hex(), + "wss://other-scope.example", + config("other-host"), + ); + let mut other = current.clone(); + other.pubkey = nostr::Keys::generate().public_key().to_hex(); + other.private_key_nsec = "other-inline-fixture".into(); + write_raw(&fixture, &[current.clone(), other.clone()]); + agents::storage::save_runtime_metadata_batch( + fixture.app.handle(), + std::slice::from_ref(&stale), + ) + .unwrap(); + let saved = raw(&fixture); + assert_eq!(saved.private_key_nsec, current.private_key_nsec); + assert_eq!(saved.name, current.name); + assert_eq!( + serde_json::to_value(saved.runtime_configurations).unwrap(), + serde_json::to_value(current.runtime_configurations).unwrap() + ); + assert_eq!( + agents::storage::load_agent_store(fixture.app.handle()).unwrap()[1].private_key_nsec, + other.private_key_nsec + ); + // Ordinary edits also have no key-write authority; a stale key cannot undo + // a legitimate rotation, and no missing identity is implicitly provisioned. + agents::save_managed_agents(fixture.app.handle(), &[stale.clone(), other.clone()]).unwrap(); + assert_eq!( + raw(&fixture).private_key_nsec, + "deliberately-rotated-inline-fixture" + ); + write_raw(&fixture, &[other]); + agents::storage::save_runtime_metadata_batch( + fixture.app.handle(), + std::slice::from_ref(&stale), + ) + .unwrap(); + assert_eq!( + agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .len(), + 1 + ); + assert!(agents::save_managed_agents(fixture.app.handle(), &[stale]).is_err()); + }); +} + +#[test] +fn deliberate_new_identity_provisioning_does_not_rewrite_existing_keys() { + with_path_lock(async { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + backend.revoke(); + let new_record = record(); + let new_name = format!("agent:{}", new_record.pubkey); + assert_ne!(new_record.pubkey, fixture.record.pubkey); + let records = [fixture.record.clone(), new_record.clone()]; + assert!(agents::save_managed_agents(fixture.app.handle(), &records).is_err()); + backend.assert_absent(&fixture); + agents::storage::save_managed_agents_with_new_keys(fixture.app.handle(), &records).unwrap(); + backend.assert_absent(&fixture); + assert_eq!( + backend.store.load_fresh_readonly(&new_name).unwrap(), + Some(new_record.private_key_nsec) + ); + assert!(agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .iter() + .all(|r| r.private_key_nsec.is_empty())); + }); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/named_switch_acceptance.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/named_switch_acceptance.rs new file mode 100644 index 00000000000..34985041b6a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/named_switch_acceptance.rs @@ -0,0 +1,143 @@ +//! Practical same-host acceptance: two simultaneously stored named choices, +//! signed request bytes and native receiver/shared spawn, not mocked IPC. +//! Provider readiness and ACP children remain the existing isolated fixtures. +use super::*; + +#[tokio::test] +async fn two_named_choices_bind_explicit_start_and_restart_not_next_selection() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + let owner = fixture.owner.public_key().to_hex(); + let identity = fixture.record.pubkey.clone(); + let first = fixture.named.clone(); + let mut second = config(&first.host); + second.name = "Second".into(); + second.model = "second-model".into(); + second.workspace = first.workspace.clone(); + fixture + .record + .runtime_configurations + .replace( + &owner, + COMMUNITY, + &first.host, + RuntimeConfigurations { + selected: Some(second.id.clone()), + entries: vec![first.clone(), second.clone()], + }, + ) + .unwrap(); + let choices = fixture.record.runtime_configurations.get(&owner, COMMUNITY); + second = choices + .entries + .iter() + .find(|entry| entry.id == second.id) + .unwrap() + .clone(); + fixture.persist(); + assert_eq!(choices.entries.len(), 2); + assert_eq!(choices.selected.as_deref(), Some(second.id.as_str())); + + // Explicit first is deliberately NOT the selected next configuration. + // Round-trip encrypted, signed event bytes without inventing a transport. + let request = fixture.request(Action::Start, None); + let wire = serde_json::to_vec(&request).unwrap(); + let received: Event = serde_json::from_slice(&wire).unwrap(); + assert_eq!(received, request); + let started = fixture + .receive(received, |model, allow| async move { + assert!(model.is_none()); + assert!(!allow); + Ok(()) + }) + .await; + assert_eq!(started.outcome, Outcome::Running); + assert_eq!( + started.observation.unwrap().running_configuration, + Some(first.reference()) + ); + fixture.launched("fixture-model|fixture-model"); + let original = fixture.running().unwrap(); + println!("ACCEPTANCE: two named choices; next=Second; explicit First started; child confirmed exact First model env"); + + // Edit First while it runs: receipt remains its launched revision, not the + // edited revision and not Second (the selected next launch). + let mut edited = first.clone(); + edited.model = "edited-first-model".into(); + fixture + .record + .runtime_configurations + .replace( + &owner, + COMMUNITY, + &first.host, + RuntimeConfigurations { + selected: Some(second.id.clone()), + entries: vec![edited, second.clone()], + }, + ) + .unwrap(); + fixture.persist(); + let status = fixture.action(Action::Status, None).await; + assert_eq!(status.outcome, Outcome::Running); + assert_eq!( + status.observation.unwrap().running_configuration, + Some(first.reference()) + ); + assert_eq!(fixture.running().unwrap(), original); + // Start of the exact already-running reference is an idempotent running + // observation, even after its definition was edited. Eligibility for a NEW + // launch is separate: the stale reference must fail Preflight. + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + assert_eq!( + fixture.action(Action::Preflight, None).await.outcome, + Outcome::Ineligible + ); + assert_eq!(fixture.running().unwrap(), original); + println!("ACCEPTANCE: edit changed next-launch revision only; old revision ineligible for new launch; idempotent Start reports actual running PID/receipt unchanged"); + + fixture.named = second.clone(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::DifferentConfiguration + ); + assert_eq!(fixture.running().unwrap(), original); + let failed = fixture + .receive( + fixture.request(Action::Restart, Some(status.id)), + |_, _| async { Err("isolated readiness refusal".into()) }, + ) + .await; + assert_eq!(failed.outcome, Outcome::Ineligible); + assert_eq!(fixture.running().unwrap(), original); + println!("ACCEPTANCE: Second readiness failure refused before Stop; First still alive"); + + let status = fixture.action(Action::Status, None).await; + let switched = fixture.action(Action::Restart, Some(status.id)).await; + assert_eq!(switched.outcome, Outcome::Running); + assert_eq!( + switched.observation.unwrap().running_configuration, + Some(second.reference()) + ); + let replacement = fixture.running().unwrap(); + assert_ne!(replacement.0, original.0); + assert_eq!(replacement.1, Some(second.reference())); + assert_eq!(fixture.record.pubkey, identity); + fixture.launched("second-model|second-model"); + assert_eq!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .lines() + .collect::>(), + vec!["fixture-model|fixture-model", "second-model|second-model"] + ); + println!("ACCEPTANCE: same-host Restart launched exact Second; new PID; stable identity; exactly two child launches, no edited-model substitution"); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(fixture.running().is_none()); + println!( + "ACCEPTANCE: ordinary signed Stop confirmed; no child remains; model-ready NOT claimed" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs new file mode 100644 index 00000000000..075ba7d1213 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs @@ -0,0 +1,552 @@ +//! Signed production receiver → ordinary provider preflight → exact-plan child +//! registration. Only destination-local synthetic identities and bounded fake +//! children; run with --no-default-features (never a real OS credential store). +use super::*; +use buzz_core_pkg::{ + desktop_lifecycle::{Action, Outcome, Request, ResultMessage}, + desktop_stop::{StopOutcome, StopResult, StopTarget}, +}; +use nostr::{Event, EventBuilder, Timestamp}; +use std::{cell::Cell, os::unix::fs::PermissionsExt}; +use tauri::Manager; + +const COMMUNITY: &str = "wss://remote-credential-fixture.example"; + +struct Fixture { + app: tauri::App, + record: ManagedAgentRecord, + owner: nostr::Keys, + named: RuntimeConfiguration, + stamp: Cell, + env: Vec<(&'static str, Option)>, + temp: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let env = ["HOME", "XDG_DATA_HOME", "PATH", "BUZZ_PRIVATE_KEY"] + .map(|key| (key, std::env::var_os(key))) + .to_vec(); + let owner = nostr::Keys::generate(); + std::env::set_var("HOME", temp.path()); + std::env::set_var("XDG_DATA_HOME", temp.path()); + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", temp.path().display())); + std::env::set_var("BUZZ_PRIVATE_KEY", owner.secret_key().to_secret_hex()); + agents::clear_resolve_cache(); + let mut record = record(); + attest(&mut record, &owner); + // This key exists only in this isolated fixture. The child checks local + // credential delivery without writing a secret to output or diagnostics. + let child = format!( + "#!/bin/sh\n[ \"$BUZZ_PRIVATE_KEY\" = '{}' ] || exit 12\nprintf '%s|%s\\n' \"$BUZZ_ACP_REQUIRED_MODEL\" \"$BUZZ_ACP_MODEL\" >> '{}'\nexec /bin/sleep 20\n", + record.private_key_nsec, temp.path().join("launches").display() + ); + for (name, script) in [ + ("buzz-acp", child.as_str()), + ("buzz-agent", "#!/bin/sh\nexit 0\n"), + ("buzz-dev-mcp", "#!/bin/sh\nexit 0\n"), + ] { + let path = temp.path().join(name); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + *app.state::() + .relay_url_override + .lock() + .unwrap() = Some(COMMUNITY.into()); + assert_eq!( + app.state::() + .signing_keys() + .unwrap() + .public_key(), + owner.public_key() + ); + record.acp_command = temp.path().join("buzz-acp").display().to_string(); + record.runtime = Some("buzz-agent".into()); + record.agent_command = "buzz-agent".into(); + record + .env_vars + .insert("OPENAI_COMPAT_API_KEY".into(), "fixture-only".into()); + let host = local_host(app.handle(), &owner.public_key().to_hex(), COMMUNITY).unwrap(); + let mut named = config(&host); + named.workspace = Some(temp.path().display().to_string()); + let named = save(&mut record, &owner.public_key().to_hex(), COMMUNITY, named); + let fixture = Self { + app, + record, + owner, + named, + stamp: Cell::new(Timestamp::now().as_secs().saturating_sub(10)), + env, + temp, + }; + fixture.persist(); + fixture + } + fn persist(&self) { + agents::storage::save_managed_agents_with_new_keys( + self.app.handle(), + &[self.record.clone()], + ) + .unwrap(); + } + // Simulate an independent destination credential writer, not a metadata edit. + fn write_raw(&self, record: &ManagedAgentRecord) { + let path = agents::storage::managed_agents_store_path(self.app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted( + &path, + &serde_json::to_vec(&[record]).unwrap(), + ) + .unwrap(); + } + fn target(&self) -> StopTarget { + StopTarget { + v: 1, + community: COMMUNITY.into(), + desktop: self.named.host.clone(), + agent: self.record.pubkey.clone(), + } + } + fn ordered(&self, event: Event) -> Event { + let stamp = self.stamp.get() + 1; + self.stamp.set(stamp); + EventBuilder::new(event.kind, event.content) + .tags(event.tags.iter().cloned()) + .custom_created_at(Timestamp::from_secs(stamp)) + .sign_with_keys(&self.owner) + .unwrap() + } + fn request(&self, action: Action, observed: Option) -> Event { + self.ordered( + Request { + target: self.target(), + action, + observed, + configuration: (!matches!(action, Action::Catalog | Action::Status)) + .then(|| self.named.reference()), + cursor: None, + } + .sign(&self.owner) + .unwrap(), + ) + } + async fn receive(&self, event: Event, preflight: F) -> ResultMessage + where + F: Fn(Option, bool) -> Fut, + Fut: std::future::Future>, + { + let response = crate::commands::receive_desktop_lifecycle_with( + self.app.handle().clone(), + self.owner.public_key().to_hex(), + COMMUNITY.into(), + event.clone(), + preflight, + ) + .await + .unwrap() + .unwrap(); + let result = ResultMessage::read(&response, &self.owner, &event, COMMUNITY).unwrap(); + let safe = serde_json::to_string(&result).unwrap(); + assert!( + !safe.contains(&self.record.private_key_nsec) + || self.record.private_key_nsec.is_empty() + ); + assert!(!safe.contains("fixture-only")); + assert!(!safe.contains(self.temp.path().to_str().unwrap())); + result + } + async fn action(&self, action: Action, observed: Option) -> ResultMessage { + self.receive(self.request(action, observed), |model, allow| async move { + assert!(model.is_none()); + assert!(!allow); + Ok(()) + }) + .await + } + fn running(&self) -> Option<(u32, Option)> { + let state = self.app.state::(); + let mut runtimes = state.managed_agent_processes.lock().unwrap(); + let key = agents::ManagedAgentRuntimeKey::new(&self.record.pubkey, COMMUNITY).unwrap(); + runtimes.get_mut(&key).map(|runtime| { + assert!(runtime.child.try_wait().unwrap().is_none()); + ( + runtime.child.id(), + runtime.spawn_config.runtime_configuration.clone(), + ) + }) + } + fn launched(&self, expected: &str) { + for _ in 0..100 { + if std::fs::read_to_string(self.temp.path().join("launches")) + .unwrap_or_default() + .contains(expected) + { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("fixture child did not confirm exact model and destination-local credential"); + } + async fn stop(&self) -> StopOutcome { + let event = self.ordered(self.target().sign(&self.owner).unwrap()); + let response = crate::commands::desktop_stop::receive_desktop_stop_for_app( + self.app.handle().clone(), + self.owner.public_key().to_hex(), + COMMUNITY.into(), + event.clone(), + ) + .await + .unwrap() + .unwrap(); + StopResult::read(&response, &self.owner, &event, COMMUNITY) + .unwrap() + .outcome + } +} +impl Drop for Fixture { + fn drop(&mut self) { + for (_, mut runtime) in self + .app + .state::() + .managed_agent_processes + .lock() + .unwrap() + .drain() + { + let _ = agents::terminate_process(runtime.child.id()); + let _ = runtime.child.wait(); + } + for (key, value) in self.env.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + agents::clear_resolve_cache(); + } +} + +#[tokio::test] +async fn provisioned_destination_catalog_start_and_same_host_switch_use_shared_launch() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + let catalog = fixture.action(Action::Catalog, None).await; + assert_eq!(catalog.outcome, Outcome::Ready); + assert!( + catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Preflight, None).await.outcome, + Outcome::Ready + ); + let started = fixture.action(Action::Start, None).await; + assert_eq!(started.outcome, Outcome::Running); + assert_eq!( + started.observation.unwrap().running_configuration, + Some(fixture.named.reference()) + ); + fixture.launched("fixture-model|fixture-model"); + let old_pid = fixture.running().unwrap().0; + let status = fixture.action(Action::Status, None).await; + let mut changed = fixture.named.clone(); + changed.model = "switched-model".into(); + fixture.named = save( + &mut fixture.record, + &fixture.owner.public_key().to_hex(), + COMMUNITY, + changed, + ); + fixture.persist(); + let switched = fixture.action(Action::Restart, Some(status.id)).await; + assert_eq!(switched.outcome, Outcome::Running); + assert_ne!(fixture.running().unwrap().0, old_pid); + assert_eq!( + fixture.running().unwrap().1, + Some(fixture.named.reference()) + ); + fixture.launched("switched-model|switched-model"); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(fixture.running().is_none()); + // Deliberate same-host Stop → fresh Start consumes the new Stop fence, + // unlike a stale Restart continuation. Keep the exact switched revision. + assert_eq!( + fixture.action(Action::Preflight, None).await.outcome, + Outcome::Ready + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + assert_eq!( + fixture.running().unwrap().1, + Some(fixture.named.reference()) + ); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); +} + +#[tokio::test] +async fn unavailable_identity_or_runtime_excludes_catalog_and_refuses_start() { + let _guard = agents::lock_path_mutex_async().await; + for missing in ["absent", "wrong", "runtime"] { + let mut fixture = Fixture::new(); + match missing { + "absent" => fixture.record.private_key_nsec.clear(), + "wrong" => { + fixture.record.private_key_nsec = + nostr::Keys::generate().secret_key().to_secret_hex() + } + _ => { + // Cache was warmed by catalog readiness. Revocation must still stat. + assert!(agents::resolve_command(&fixture.record.acp_command).is_some()); + std::fs::remove_file(&fixture.record.acp_command).unwrap(); + } + } + fixture.write_raw(&fixture.record); + let catalog = fixture.action(Action::Catalog, None).await; + assert_eq!(catalog.outcome, Outcome::Ready); + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + assert!(fixture.running().is_none()); + assert!(!fixture.temp.path().join("launches").exists()); + } +} + +#[tokio::test] +async fn revoked_key_keeps_existing_process_visible_and_restart_refuses_without_teardown() { + let _guard = agents::lock_path_mutex_async().await; + let mut fixture = Fixture::new(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + fixture.record.private_key_nsec.clear(); + fixture.write_raw(&fixture.record); + let status = fixture.action(Action::Status, None).await; + assert_eq!(status.outcome, Outcome::Running); + assert_eq!( + fixture + .action(Action::Restart, Some(status.id)) + .await + .outcome, + Outcome::Ineligible + ); + assert_eq!(fixture.running().unwrap().0, pid); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(fixture.running().is_none()); +} + +#[tokio::test] +async fn key_loss_during_preflight_is_rechecked_for_start_and_catalog() { + let _guard = agents::lock_path_mutex_async().await; + for action in [Action::Start, Action::Catalog] { + let fixture = Fixture::new(); + let result = fixture + .receive(fixture.request(action, None), |_, _| { + let mut revoked = fixture.record.clone(); + revoked.private_key_nsec.clear(); + fixture.write_raw(&revoked); + async { Ok(()) } + }) + .await; + if action == Action::Start { + assert_eq!(result.outcome, Outcome::Ineligible); + } else { + assert!( + !result + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + } + assert!(fixture.running().is_none()); + assert!(!fixture.temp.path().join("launches").exists()); + } +} + +#[tokio::test] +async fn failed_destination_preflight_never_tears_down_existing_source() { + let _guard = agents::lock_path_mutex_async().await; + let fixture = Fixture::new(); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + let result = fixture + .receive(fixture.request(Action::Preflight, None), |_, _| async { + Err("isolated provider unavailable".into()) + }) + .await; + assert_eq!(result.outcome, Outcome::Ineligible); + assert_eq!(fixture.running().unwrap().0, pid); +} + +// The fake child changes only its temporary fixture files when ordinary Stop +// signals it. This binds revocation to the actual teardown boundary, after both +// async and locked admission have already accepted the old key/executable. +fn on_stop(fixture: &Fixture, effect: &str) { + let script = std::fs::read_to_string(&fixture.record.acp_command).unwrap(); + let script = script + .replacen( + "#!/bin/sh\n", + &format!("#!/bin/sh\ntrap '{}' TERM\n", effect), + 1, + ) + .replace("exec /bin/sleep 20", "/bin/sleep 20 &\nwait"); + std::fs::write(&fixture.record.acp_command, script).unwrap(); +} + +fn assert_failed_without_child(fixture: &Fixture) -> ManagedAgentRecord { + assert!(fixture.running().is_none()); + let saved = agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == fixture.record.pubkey) + .unwrap(); + assert!(saved.runtime_pid.is_none()); + assert!(saved.last_stopped_at.is_some()); + assert!(saved.last_error.is_some()); + assert_eq!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .lines() + .count(), + 1 + ); + saved +} + +#[tokio::test] +async fn post_stop_key_loss_or_executable_loss_persists_failed_without_restoring_credentials() { + let _guard = agents::lock_path_mutex_async().await; + for revoke_key in [true, false] { + let fixture = Fixture::new(); + let store_path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + let revoked_path = fixture.temp.path().join("revoked.json"); + let mut revoked = fixture.record.clone(); + revoked.private_key_nsec.clear(); + std::fs::write(&revoked_path, serde_json::to_vec(&[revoked]).unwrap()).unwrap(); + let effect = if revoke_key { + format!( + "/bin/cp \"{}\" \"{}\"; exit 0", + revoked_path.display(), + store_path.display() + ) + } else { + format!("/bin/rm \"{}\"; exit 0", fixture.record.acp_command) + }; + on_stop(&fixture, &effect); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let status = fixture.action(Action::Status, None).await; + let result = fixture.action(Action::Restart, Some(status.id)).await; + assert_eq!(result.outcome, Outcome::Failed); + let saved = assert_failed_without_child(&fixture); + if revoke_key { + assert!(saved.private_key_nsec.is_empty()); + assert!(!std::fs::read_to_string(store_path) + .unwrap() + .contains(&fixture.record.private_key_nsec)); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + } else { + assert_eq!(saved.private_key_nsec, fixture.record.private_key_nsec); + } + } +} + +#[tokio::test] +async fn post_stop_expiry_persists_truthful_failed_without_second_spawn() { + let _guard = agents::lock_path_mutex_async().await; + let fixture = Fixture::new(); + // TERM is ignored only by this bounded synthetic process; ordinary Stop + // takes its one-second grace then SIGKILL, crossing the captured deadline. + on_stop(&fixture, ""); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let mut plan = prepare_for_app( + fixture.app.handle(), + &fixture.record, + Some(&fixture.named.reference()), + &fixture.owner.public_key().to_hex(), + COMMUNITY, + ) + .unwrap(); + preflight_with( + &mut plan, + &fixture.owner.public_key().to_hex(), + COMMUNITY, + false, + |_, _| async { Ok(()) }, + ) + .await + .unwrap(); + let state = fixture.app.state::(); + let _transition = state.managed_agent_runtime_transition.lock().unwrap(); + plan.expire_at(Timestamp::now().as_secs() + 1); + let status = agents::start_pair_captured_locked( + fixture.record.pubkey.clone(), + COMMUNITY.into(), + true, + None, + None, + &plan, + false, + true, + None, + fixture.app.handle().clone(), + ) + .unwrap(); + assert_eq!( + status.lifecycle, + agents::ManagedAgentRuntimeLifecycle::Failed + ); + assert!(status.error.unwrap().contains("expired")); + let saved = assert_failed_without_child(&fixture); + assert_eq!(saved.private_key_nsec, fixture.record.private_key_nsec); +} + +#[path = "credential_persistence.rs"] +mod credential_persistence; + +#[path = "named_switch_acceptance.rs"] +mod named_switch_acceptance; diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 4862cedbae3..1660a88b085 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -1,8 +1,67 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use url::{Host, Url}; use super::ManagedAgentProcess; +pub(crate) const RUNTIME_AUTHORITY_RECEIPT_VERSION: u8 = 1; + +/// Canonicalize only URL syntax that cannot distinguish relay authorities. +/// +/// In particular, loopback host spellings stay literal: relay tenancy and +/// lifecycle fences distinguish `localhost`, `127.*`, and `::1`. The shared +/// buzz-core normalizer predates that boundary and deliberately remains in use +/// by Bestie and migration consumers whose compatibility rules differ. +fn normalize_runtime_relay_url(raw: &str) -> Result { + let mut url = Url::parse(raw.trim()).map_err(|error| format!("invalid relay URL: {error}"))?; + if !matches!(url.scheme(), "ws" | "wss") { + return Err("relay URL scheme must be ws or wss".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("relay URL must not contain credentials".into()); + } + if url.fragment().is_some() { + return Err("relay URL must not contain a fragment".into()); + } + + let host = url + .host() + .ok_or_else(|| "relay URL must contain a host".to_string())?; + if let Host::Domain(domain) = host { + let lowercase = domain.to_ascii_lowercase(); + url.set_host(Some(&lowercase)) + .map_err(|_| "relay URL must contain a host".to_string())?; + } + + let default_port = match url.scheme() { + "ws" => Some(80), + "wss" => Some(443), + _ => None, + }; + if url.port() == default_port { + url.set_port(None) + .map_err(|_| "relay URL scheme must be ws or wss".to_string())?; + } + let host = match url + .host() + .ok_or_else(|| "relay URL must contain a host".to_string())? + { + Host::Domain(domain) => domain.to_string(), + Host::Ipv4(address) => address.to_string(), + Host::Ipv6(address) => format!("[{address}]"), + }; + let port = url + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + let path = if url.path() == "/" { "" } else { url.path() }; + let query = url + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(); + Ok(format!("{}://{host}{port}{path}{query}", url.scheme())) +} + /// Canonical identity of one managed-agent harness on one relay. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] @@ -19,8 +78,7 @@ impl ManagedAgentRuntimeKey { } Ok(Self { pubkey: pubkey.to_ascii_lowercase(), - relay_url: buzz_core_pkg::relay::normalize_relay_url(relay_url) - .map_err(|error| error.to_string())?, + relay_url: normalize_runtime_relay_url(relay_url)?, }) } @@ -81,6 +139,7 @@ impl ManagedAgentPairRuntime { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeStatus { + pub running_configuration: Option, pub pubkey: String, pub relay_url: String, /// Exact descriptor URL echoed only by reconcile result rows so callers can @@ -113,8 +172,109 @@ pub struct ManagedAgentCommunityTarget { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeReceipt { + #[serde(default)] + pub runtime_configuration: Option, + /// Version 0 is an unversioned legacy receipt. Its lossy host/path rendering + /// cannot prove pair authority; it is usable only for instance-wide cleanup. + #[serde(default)] + pub authority_version: u8, pub key: ManagedAgentRuntimeKey, pub pid: u32, pub desktop_instance_id: String, pub started_at: String, } + +impl ManagedAgentRuntimeReceipt { + pub(crate) fn new( + key: ManagedAgentRuntimeKey, + pid: u32, + desktop_instance_id: String, + started_at: String, + ) -> Self { + Self { + runtime_configuration: None, + authority_version: RUNTIME_AUTHORITY_RECEIPT_VERSION, + key, + pid, + desktop_instance_id, + started_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(relay_url: &str) -> ManagedAgentRuntimeKey { + ManagedAgentRuntimeKey::new("aa".repeat(32), relay_url).unwrap() + } + + #[test] + fn runtime_identity_preserves_distinct_loopback_authorities() { + let localhost = key("ws://localhost:3000"); + let ipv4 = key("ws://127.0.0.1:3000"); + let other_ipv4 = key("ws://127.0.0.2:3000"); + let ipv6 = key("ws://[::1]:3000"); + + assert_eq!(localhost.relay_url, "ws://localhost:3000"); + assert_eq!(ipv4.relay_url, "ws://127.0.0.1:3000"); + assert_eq!(other_ipv4.relay_url, "ws://127.0.0.2:3000"); + assert_eq!(ipv6.relay_url, "ws://[::1]:3000"); + assert_ne!(localhost, ipv4); + assert_ne!(ipv4, other_ipv4); + assert_ne!(ipv4, ipv6); + } + + #[test] + fn runtime_identity_preserves_paths_queries_and_meaningful_trailing_slashes() { + assert_eq!( + key(" WSS://Relay.Example:443/community/?mode=one ").relay_url, + "wss://relay.example/community/?mode=one" + ); + assert_ne!( + key("wss://relay.example/community").relay_url, + key("wss://relay.example/community/").relay_url + ); + assert_eq!( + key("wss://relay.example/?").relay_url, + "wss://relay.example?" + ); + assert_ne!( + key("wss://relay.example").relay_url, + key("wss://relay.example/?").relay_url + ); + } + + #[test] + fn runtime_identity_rejects_non_websocket_credentials_and_fragments() { + for relay_url in [ + "https://relay.example", + "wss://user@relay.example", + "wss://relay.example/#", + "wss://relay.example/#fragment", + ] { + assert!(ManagedAgentRuntimeKey::new("aa".repeat(32), relay_url).is_err()); + } + } + + #[test] + fn receipt_authority_version_distinguishes_new_and_unversioned_receipts() { + let receipt = ManagedAgentRuntimeReceipt::new( + key("ws://localhost:3000"), + 42, + "instance".into(), + "now".into(), + ); + assert_eq!(receipt.authority_version, RUNTIME_AUTHORITY_RECEIPT_VERSION); + + let legacy: ManagedAgentRuntimeReceipt = serde_json::from_value(serde_json::json!({ + "key": receipt.key, + "pid": 42, + "desktopInstanceId": "instance", + "startedAt": "now" + })) + .unwrap(); + assert_eq!(legacy.authority_version, 0); + } +} diff --git a/desktop/src-tauri/src/managed_agents/session_policy.rs b/desktop/src-tauri/src/managed_agents/session_policy.rs index eb723908cab..6c903860d9b 100644 --- a/desktop/src-tauri/src/managed_agents/session_policy.rs +++ b/desktop/src-tauri/src/managed_agents/session_policy.rs @@ -3,8 +3,6 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; -use tauri::{AppHandle, Manager}; - use crate::app_state::AppState; pub(crate) const ACP_SESSION_POLICY_ENV_VAR: &str = "BUZZ_ACP_SESSION_POLICY"; @@ -69,6 +67,7 @@ pub(crate) fn acp_session_policy(state: &AppState) -> AcpSessionPolicy { ) } +/// Apply the captured launch policy after inherited and user environment. pub(crate) fn apply_acp_session_policy_env( command: &mut std::process::Command, policy: AcpSessionPolicy, @@ -76,18 +75,6 @@ pub(crate) fn apply_acp_session_policy_env( command.env(ACP_SESSION_POLICY_ENV_VAR, policy.as_str()); } -/// Resolve the effective policy, apply it to `command`, and return it so the -/// caller can stamp the same value onto the spawn snapshot (env and badge can -/// never disagree about what the child launched with). -pub(crate) fn apply_app_acp_session_policy_env( - app: &AppHandle, - command: &mut std::process::Command, -) -> AcpSessionPolicy { - let policy = acp_session_policy(app.state::().inner()); - apply_acp_session_policy_env(command, policy); - policy -} - pub(crate) fn insert_acp_session_policy_env( policy_env: &mut BTreeMap, policy: AcpSessionPolicy, @@ -133,8 +120,20 @@ mod tests { let mut command = std::process::Command::new("true"); command.env(ACP_SESSION_POLICY_ENV_VAR, "ambient"); - apply_acp_session_policy_env(&mut command, AcpSessionPolicy::Thread); + let mut prepared_env = BTreeMap::new(); + insert_acp_session_policy_env(&mut prepared_env, AcpSessionPolicy::Thread); + command.envs(&prepared_env); assert_eq!(command_policy(&command), Some("thread")); } + + #[test] + fn captured_local_policy_overrides_user_env_without_reading_live_state() { + for policy in [AcpSessionPolicy::Channel, AcpSessionPolicy::Thread] { + let mut command = std::process::Command::new("true"); + command.env(ACP_SESSION_POLICY_ENV_VAR, "ambient"); + apply_acp_session_policy_env(&mut command, policy); + assert_eq!(command_policy(&command), Some(policy.as_str())); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 810ad439f29..8192bd1e4fd 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -101,6 +101,7 @@ pub(crate) struct SpawnConfigInputs<'a> { /// [`ManagedAgentProcess`]: super::ManagedAgentProcess #[derive(Clone, Serialize)] pub(crate) struct SpawnConfigSnapshot { + pub runtime_configuration: Option, /// The ACP harness binary the desktop launches (`buzz-acp`). pub acp_command: String, /// The effective agent command the harness drives. @@ -180,6 +181,14 @@ pub(crate) fn effective_effort(descriptor: &EffectiveHarnessDescriptor) -> Optio impl SpawnConfigSnapshot { /// Assemble the snapshot from values a spawn has already resolved. + /// Read by `mesh_llm::recovery` to collect the mesh consumer model ids of + /// live pairs; dead only in default-feature builds, where `mesh-llm` is + /// off and that consumer is compiled out. + #[cfg_attr(not(feature = "mesh-llm"), allow(dead_code))] + pub(crate) fn relay_mesh_model_id(&self) -> Option { + super::resolved_relay_mesh_model_id(self.provider.as_deref(), self.model.as_deref()) + } + pub(crate) fn from_inputs(inputs: SpawnConfigInputs<'_>) -> Self { let SpawnConfigInputs { record, @@ -195,6 +204,10 @@ impl SpawnConfigSnapshot { let (respond_to, respond_to_allowlist) = super::projected_access_with_policy(record, enforced_owner_only); Self { + runtime_configuration: super::runtime_configurations::selected(record) + .ok() + .flatten() + .map(super::runtime_configurations::RuntimeConfiguration::reference), acp_command: record.acp_command.clone(), command: descriptor.command.clone(), args: descriptor.args.clone(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index 43ce7718595..47452a30129 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -8,6 +8,12 @@ const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL"; /// coverage guard below sees the full serialized key set. fn base() -> SpawnConfigSnapshot { SpawnConfigSnapshot { + runtime_configuration: Some( + crate::managed_agents::runtime_configurations::RuntimeConfigurationRef { + id: "fixture-configuration".into(), + revision: "fixture-revision".into(), + }, + ), acp_command: "buzz-acp".into(), command: "goose".into(), args: vec!["--mode".into(), "acp".into()], @@ -50,6 +56,7 @@ type Mutation = (&'static str, fn(&mut SpawnConfigSnapshot)); fn mutations() -> Vec { vec![ + ("runtime_configuration", |s| s.runtime_configuration = None), ("acp_command", |s| s.acp_command = "other-acp".into()), ("command", |s| s.command = "claude".into()), ("args", |s| s.args = vec!["--other".into()]), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 388256e01c6..f3a4012c5ac 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -43,6 +43,7 @@ fn snap(record: &ManagedAgentRecord) -> serde_json::Value { fn record() -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: "p".repeat(64), name: "agent".into(), diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a8..b34a1fc7fed 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -25,6 +25,10 @@ fn agent_keyring_name(pubkey: &str) -> String { /// and therefore one in-memory cache and one mutex — preventing last-writer-wins /// races on concurrent blob writes. fn agent_secret_store() -> Option<&'static SecretStore> { + #[cfg(all(test, unix, not(feature = "system-keyring")))] + if let Some(store) = *TEST_AGENT_SECRET_STORE.lock().unwrap() { + return Some(store); + } if cfg!(feature = "system-keyring") { Some(SecretStore::shared(keyring_service())) } else { @@ -48,7 +52,7 @@ pub(crate) fn managed_agents_store_path( Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) } -fn managed_agents_logs_dir(app: &AppHandle) -> Result { +fn managed_agents_logs_dir(app: &AppHandle) -> Result { let dir = managed_agents_base_dir(app)?.join("logs"); fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?; Ok(dir) @@ -82,14 +86,18 @@ fn is_safe_id_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '-' || c == '_' } -pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { +/// Legacy per-agent log path, also available to isolated runtime fixtures. +pub fn managed_agent_log_path( + app: &AppHandle, + pubkey: &str, +) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) } /// Pair-scoped log path for a managed runtime. The relay URL never appears in /// the filename; the suffix is a hash of the canonical URL. -pub fn managed_agent_runtime_log_path( - app: &AppHandle, +pub fn managed_agent_runtime_log_path( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) @@ -137,7 +145,7 @@ trait KeyStore { fn probe(&self, name: &str) -> KeyringProbe; /// Read a key. `Ok(None)` is "no such entry" (absent); `Err` is a backend /// failure (keyring unreachable) — the caller MUST NOT collapse the two. - fn load(&self, name: &str) -> Result, String>; + fn load_fresh_readonly(&self, name: &str) -> Result, String>; /// Read the entire blob as a map without any side effects. /// `Ok(None)` when no blob exists yet; `Err` only on backend failure. /// Callers must not call `migrate_legacy_key` — this is a read-only view. @@ -153,8 +161,8 @@ impl KeyStore for SecretStore { fn probe(&self, name: &str) -> KeyringProbe { SecretStore::probe(self, name) } - fn load(&self, name: &str) -> Result, String> { - SecretStore::load(self, name) + fn load_fresh_readonly(&self, name: &str) -> Result, String> { + SecretStore::load_fresh_readonly(self, name) } fn load_all_readonly(&self) -> Result>, String> { SecretStore::load_all_readonly(self) @@ -191,9 +199,8 @@ enum KeyMigration { /// verify. Pure decision logic — does NOT mutate the record, so the caller /// chooses whether to strip the inline copy based on the returned outcome. /// -/// The single source of truth for the migrate-vs-keep decision, shared by the -/// load-time opportunistic re-migrate ([`hydrate_keys`]) and the save-time -/// chokepoint ([`persist_agent_keys`]). An empty key returns +/// The single source of truth for the migrate-vs-keep decision, used only by the +/// explicit new-identity provisioning chokepoint ([`persist_agent_keys`]). An empty key returns /// [`KeyMigration::Nothing`] — never [`KeyMigration::Persisted`], so a record /// left empty by a keyring outage is not mistaken for one verified present. fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> KeyMigration { @@ -221,7 +228,7 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key } /// Refuse to spawn an agent whose private key is unavailable. Returns -/// `Some(error)` when `private_key_nsec` is empty — after [`hydrate_keys`] an +/// `Some(error)` when `private_key_nsec` is empty — after [`load_managed_agents`] an /// empty key means a keyring outage or a genuinely absent secret, NOT a /// deliberately keyless agent. Spawning anyway would inject an empty /// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers @@ -238,7 +245,7 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { /// Read the raw unified store — keyed instances AND key-less definitions — /// with fail-loud parse handling. Internal seam; public readers filter. -fn load_agent_store( +pub(crate) fn load_agent_store( app: &AppHandle, ) -> Result, String> { let path = managed_agents_store_path(app)?; @@ -261,14 +268,25 @@ fn load_agent_store( } /// Load the keyed agent *instances*. Key-less definitions (former personas, -/// folded into the same store) are filtered out so every pre-fold call site -/// keeps seeing exactly the records it always did. +/// folded into the same store) are filtered out. Reads observe currently +/// provisioned credentials without cache hydration or migration side effects. pub fn load_managed_agents( app: &AppHandle, +) -> Result, String> { + load_managed_agents_for_launch(app) +} + +/// Read launch inputs without migrating keys or trusting a warm keyring cache. +/// Missing/unreadable credentials remain unavailable, but do not hide the record +/// from Status/Stop. Inline keys are the existing destination-local file fallback. +pub(crate) fn load_managed_agents_for_launch( + app: &AppHandle, ) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); - hydrate_keys(&mut records); + if let Some(store) = agent_secret_store() { + hydrate_keys_with(store, &mut records); + } Ok(records) } @@ -300,24 +318,7 @@ pub(crate) fn backup_invalid_store(path: &Path) { } } -/// Fill in each record's in-memory `private_key_nsec` from the keyring, and -/// opportunistically re-migrate any key that is still inline. -/// -/// - Empty key → fetch it from the keyring (the normal keyring-backed case). -/// - Non-empty key → the JSON carried it inline because the keyring was -/// unreachable at its last save. Re-migrate it now ([`migrate_inline_key`]): -/// if the keyring is reachable this boot, write-verify-strip so the next save -/// writes clean JSON and plaintext stops lingering on disk; if still -/// unreachable, leave it inline. This makes the strip deterministic on the -/// next reachable boot rather than waiting for a non-deterministic save. -fn hydrate_keys(records: &mut [ManagedAgentRecord]) { - let Some(store) = agent_secret_store() else { - return; - }; - hydrate_keys_with(store, records); -} - -/// Testable core of [`hydrate_keys`], generic over the [`KeyStore`] seam. +/// Fresh read-only hydration, shared by ordinary reads and launch admission. /// /// A keyring LOAD error (`Err`) is an OUTAGE — distinct from `Ok(None)` /// (genuinely absent). On an outage the key is left empty and the record is @@ -333,7 +334,7 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) continue; } if record.private_key_nsec.is_empty() { - match store.load(&agent_keyring_name(&record.pubkey)) { + match store.load_fresh_readonly(&agent_keyring_name(&record.pubkey)) { Ok(Some(nsec)) => record.private_key_nsec = nsec, Ok(None) => { eprintln!( @@ -352,44 +353,103 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) ); } } - } else { - // Inline residue from a prior keyring-unreachable save. Lift it - // into the keyring now (side effect) but KEEP it in memory — the - // returned record must carry the key for readers. The next save - // then strips it from JSON. Outcome is intentionally ignored: - // on failure the key simply stays inline until a later boot. - let _ = migrate_inline_key(store, record); } } } -/// Save the keyed agent *instances*, preserving the key-less definitions that -/// share the unified store: callers pass exactly the records they loaded via -/// [`load_managed_agents`], and this re-reads the definition half from disk -/// before the wholesale rewrite so a definition is never dropped by an -/// instance-side save (and vice versa via [`save_agent_definitions`]). +/// Save instance edits/deletions without credential-write authority. Existing +/// inline keys come from the current raw store, never the supplied hydrated +/// records; the secret backend is not written. New identities must use the +/// explicit creation/provisioning entry point. Caller holds the store lock. pub fn save_managed_agents( app: &AppHandle, records: &[ManagedAgentRecord], ) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); + save_agent_edits(app, records, false) +} + +/// Persist deliberately created identities, migrating ONLY newly inserted keys. +/// Existing agents' credentials are still owned by current storage. This is for +/// mint/import commits, never lifecycle status, cleanup, or launch preparation. +pub(crate) fn save_managed_agents_with_new_keys( + app: &AppHandle, + records: &[ManagedAgentRecord], +) -> Result<(), String> { + save_agent_edits(app, records, true) +} + +fn save_agent_edits( + app: &AppHandle, + records: &[ManagedAgentRecord], + provision_new: bool, +) -> Result<(), String> { + let current = load_agent_store(app)?; let mut sorted = records.to_vec(); - // A caller-supplied key-less record would collide with the definition - // half re-read below; instances always carry a pubkey. sorted.retain(|record| !record.pubkey.is_empty()); + // Validate the whole edit before performing any intentional provisioning. + if !provision_new + && sorted + .iter() + .any(|r| !current.iter().any(|c| c.pubkey == r.pubkey)) + { + return Err("New agent keys require explicit provisioning".into()); + } + for record in &mut sorted { + if let Some(saved) = current.iter().find(|r| r.pubkey == record.pubkey) { + record.private_key_nsec = saved.private_key_nsec.clone(); + } else { + persist_agent_keys(std::slice::from_mut(record)); + } + } sorted.sort_by(|left, right| { left.name .to_lowercase() .cmp(&right.name.to_lowercase()) .then_with(|| left.pubkey.cmp(&right.pubkey)) }); + let definitions = current + .into_iter() + .filter(|r| r.pubkey.is_empty()) + .collect(); + write_agent_store(app, definitions, sorted) +} - // Persist each key to the keyring; on success blank the inline copy so it - // is skipped from JSON (`skip_serializing_if = "String::is_empty"`). If the - // keyring is unreachable, the key stays inline. - persist_agent_keys(&mut sorted); +/// Persist only lifecycle bookkeeping from a launch, Stop, or cleanup. Caller holds the +/// store lock. Never feed captured/hydrated keys into the migration save path: +/// the fresh raw store owns inline credentials, and the keyring is not written. +/// Re-read even on failure so a revoked key or removed record stays removed. +pub(crate) fn save_runtime_metadata( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Result<(), String> { + save_runtime_metadata_batch(app, std::slice::from_ref(record)) +} - write_agent_store(app, definitions, sorted) +/// Merge lifecycle bookkeeping into current raw records. Retains unrelated +/// agents, definitions, configuration scopes and current credentials, including +/// rotation or revocation during teardown. Never re-adds a removed record. +pub(crate) fn save_runtime_metadata_batch( + app: &AppHandle, + records: &[ManagedAgentRecord], +) -> Result<(), String> { + let mut current = load_agent_store(app)?; + for saved in &mut current { + let Some(record) = records + .iter() + .find(|r| !r.pubkey.is_empty() && r.pubkey == saved.pubkey) + else { + continue; + }; + saved.runtime_pid = record.runtime_pid; + saved.updated_at = record.updated_at.clone(); + saved.last_started_at = record.last_started_at.clone(); + saved.last_stopped_at = record.last_stopped_at.clone(); + saved.last_exit_code = record.last_exit_code; + saved.last_error = record.last_error.clone(); + saved.last_error_code = record.last_error_code; + } + let (definitions, instances) = current.into_iter().partition(|r| r.pubkey.is_empty()); + write_agent_store(app, definitions, instances) } /// Save the key-less agent *definitions*, preserving the keyed instances — @@ -464,8 +524,8 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec /// untouched — a dev build and a prod install can coexist without sharing /// keys after this migration. /// -/// Call this at boot before `hydrate_keys` runs (i.e. before -/// `load_managed_agents` is called) so agents find their keys on first boot +/// Call this explicit migration at boot before +/// `load_managed_agents` is called so agents find their keys on first boot /// after the service-name change. #[cfg(debug_assertions)] pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) { @@ -813,8 +873,8 @@ fn agent_pids_dir(app: &AppHandle) -> Result( + app: &AppHandle, receipt: &ManagedAgentRuntimeReceipt, ) -> Result<(), String> { let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); @@ -836,8 +896,8 @@ pub fn remove_agent_runtime_receipt_path(path: &Path) { let _ = fs::remove_file(path); } -pub fn read_all_agent_runtime_receipts( - app: &AppHandle, +pub fn read_all_agent_runtime_receipts( + app: &AppHandle, ) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { let Ok(dir) = agent_pids_dir(app) else { return Vec::new(); @@ -992,3 +1052,27 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { #[cfg(test)] #[path = "storage_tests.rs"] mod tests; + +// Fixtures serialize with the existing path-test lock. Only synthetic stores +// are installed here, and the guard resets the override before releasing it. +#[cfg(all(test, unix, not(feature = "system-keyring")))] +static TEST_AGENT_SECRET_STORE: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +pub(crate) struct TestAgentSecretStore; + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl TestAgentSecretStore { + pub(crate) fn install(store: &'static SecretStore) -> Self { + *TEST_AGENT_SECRET_STORE.lock().unwrap() = Some(store); + Self + } +} + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl Drop for TestAgentSecretStore { + fn drop(&mut self) { + *TEST_AGENT_SECRET_STORE.lock().unwrap() = None; + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index d39fcf41009..8ff12efc0f2 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -72,7 +72,7 @@ impl KeyStore for FakeKeyStore { KeyringProbe::Unreachable } } - fn load(&self, name: &str) -> Result, String> { + fn load_fresh_readonly(&self, name: &str) -> Result, String> { // An unreachable backend errors on read (outage), distinct from a // reachable backend returning `Ok(None)` for an absent entry. if !self.reachable { diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index fdeb54c4f27..9b5c4e71cd9 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -254,6 +254,7 @@ mod tests { /// Build a minimal `ManagedAgentRecord` for use as a team member. fn agent_record(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: format!("{name}-pubkey"), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index fc6f0f1a97b..430687f9340 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -167,6 +167,7 @@ fn validate_team_deletion_rejects_built_ins() { fn managed_agent(name: &str) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), description: None, pubkey: name.to_string(), name: name.to_string(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2620f0337fc..2e5a016cbf2 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -110,6 +110,7 @@ impl AgentDefinition { /// event coordinate (`d_tag = slug`) across the fold. pub fn into_agent_record(self) -> ManagedAgentRecord { ManagedAgentRecord { + runtime_configurations: Default::default(), pubkey: String::new(), name: self.display_name.clone(), persona_id: None, @@ -229,6 +230,9 @@ pub struct RelayAgentInfo { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManagedAgentRecord { + /// Named launch choices; absence preserves the existing Default behavior. + #[serde(default)] + pub runtime_configurations: super::runtime_configurations::RuntimeConfigurationStore, pub pubkey: String, pub name: String, #[serde(default)] diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c53886a..da70a841bcf 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -26,8 +26,8 @@ pub use progress::install_progress_sink; mod recovery; pub use recovery::MeshRecoveryState; pub(crate) use recovery::{ - rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, MeshRecoveryUrgency, - MeshRuntimeRecovery, + rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, running_mesh_consumers, + MeshRecoveryUrgency, MeshRuntimeRecovery, }; mod usage; diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 6398f472505..6e0dbcfd1d5 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::time::Duration; @@ -275,12 +274,9 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu .as_ref() .map(|runtime| runtime.mode()); let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; - let active_pubkeys = active_managed_agent_pubkeys(&state); - // Mesh participation is resolved through the same definition-authoritative - // path as spawn/restore (#1968): definition → global fallback. A linked - // instance's own bytes never contribute. - let personas = crate::managed_agents::load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + // Runtime snapshots, not durable Default or the next selected configuration. + // Keep the pair and generation through asynchronous ingress repair. + let consumers = running_mesh_consumers(&state); match recovery { MeshRuntimeRecovery::Live @@ -294,10 +290,7 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu app.request_restart(); return Ok(()); } - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - if !records.iter().any(|record| { - running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() - }) { + if consumers.is_empty() { // A foreground save may still be bringing up its first ingress. // Only an already-running consumer justifies an automatic app // relaunch from the background watchdog. @@ -315,26 +308,18 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu )); } MeshRuntimeRecovery::Absent => { - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - if !records.iter().any(|record| { - running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() - }) { + if consumers.is_empty() { return Ok(()); } } MeshRuntimeRecovery::Evicted => {} } - let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); - let mesh_records: Vec<_> = records - .into_iter() - .filter_map(|record| { - running_relay_mesh_model_id(&record, &active_pubkeys, &personas, &global) - .map(|mesh_model_id| (record, mesh_model_id)) - }) - .collect(); let mut first_error = None; - for (record, mesh_model_id) in &mesh_records { + for (key, nonce, mesh_model_id) in &consumers { + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } match crate::commands::mesh_llm::ensure_relay_mesh_for_record( app, Some(mesh_model_id.as_str()), @@ -343,15 +328,29 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu .await { Ok(()) => { - if let Err(error) = clear_mesh_last_error_if_set(app, &record.pubkey) { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } + if let Err(error) = clear_mesh_last_error_if_set(app, &key.pubkey) { eprintln!("buzz-mesh: failed to clear recovery error: {error}"); } } Err(error) => { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if !is_current_mesh_consumer(&state, key, nonce) { + continue; + } let message = format!( "{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}" ); - if let Err(persist_error) = persist_mesh_last_error(app, &record.pubkey, &message) { + if let Err(persist_error) = persist_mesh_last_error(app, &key.pubkey, &message) { eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}"); } first_error.get_or_insert(message); @@ -361,41 +360,47 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu first_error.map_or(Ok(()), Err) } -fn active_managed_agent_pubkeys(state: &AppState) -> HashSet { +pub(crate) fn running_mesh_consumers( + state: &AppState, +) -> Vec<( + crate::managed_agents::ManagedAgentRuntimeKey, + String, + String, +)> { state .managed_agent_processes .lock() - .map(|guard| { - guard - .keys() - .map(|key| key.pubkey.to_ascii_lowercase()) + .map(|mut runtimes| { + runtimes + .iter_mut() + .filter_map(|(key, runtime)| { + if !matches!(runtime.child.try_wait(), Ok(None)) { + return None; + } + runtime + .spawn_config + .relay_mesh_model_id() + .map(|model| (key.clone(), runtime.start_nonce.clone(), model)) + }) .collect() }) .unwrap_or_default() } -/// Effective mesh model for a record that is actively running, or `None` -/// when the record is not a running relay-mesh consumer. Resolution goes -/// through `resolve_effective_relay_mesh_model_id` (definition → global -/// fallback, #1968) so the watchdog agrees with spawn/restore about which -/// agents are mesh-backed. -fn running_relay_mesh_model_id( - record: &crate::managed_agents::ManagedAgentRecord, - active_pubkeys: &HashSet, - personas: &[crate::managed_agents::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Option { - let running = record.backend == crate::managed_agents::BackendKind::Local - && active_pubkeys.contains(&record.pubkey.to_ascii_lowercase()) - && record - .runtime_pid - .is_none_or(crate::managed_agents::process_is_running); - if !running { - return None; - } - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - record, personas, global, - ) +fn is_current_mesh_consumer( + state: &AppState, + key: &crate::managed_agents::ManagedAgentRuntimeKey, + nonce: &str, +) -> bool { + state + .managed_agent_processes + .lock() + .map(|mut runtimes| { + runtimes.get_mut(key).is_some_and(|runtime| { + runtime.start_nonce == nonce && matches!(runtime.child.try_wait(), Ok(None)) + }) + }) + .unwrap_or(false) } fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { @@ -408,7 +413,7 @@ fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + crate::managed_agents::storage::save_runtime_metadata(app, record) } fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), String> { @@ -428,65 +433,13 @@ fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), Str } record.last_error = None; record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + crate::managed_agents::storage::save_runtime_metadata(app, record) } #[cfg(test)] mod tests { use super::*; - fn mesh_record( - pubkey: &str, - runtime_pid: Option, - ) -> crate::managed_agents::ManagedAgentRecord { - let mut record = crate::managed_agents::AgentDefinition { - id: pubkey.to_string(), - display_name: pubkey.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - team_catalog_source: None, - env_vars: std::collections::BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1/".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - } - .into_agent_record(); - record.pubkey = pubkey.to_string(); - record.backend = crate::managed_agents::BackendKind::Local; - record.runtime_pid = runtime_pid; - record - } - - fn active_set(pubkeys: &[&str]) -> HashSet { - pubkeys - .iter() - .map(|pubkey| pubkey.to_ascii_lowercase()) - .collect() - } - #[tokio::test] async fn closed_port_is_distinct_from_unhealthy_bound_port() { assert_eq!( @@ -622,49 +575,6 @@ mod tests { )); } - #[test] - fn only_running_relay_mesh_agents_trigger_rearm() { - let personas: Vec = Vec::new(); - let global = crate::managed_agents::GlobalAgentConfig::default(); - - let empty = active_set(&[]); - assert!(running_relay_mesh_model_id( - &mesh_record("stopped", Some(std::process::id())), - &empty, - &personas, - &global, - ) - .is_none()); - - let active = active_set(&["live"]); - assert_eq!( - running_relay_mesh_model_id( - &mesh_record("live", Some(std::process::id())), - &active, - &personas, - &global, - ) - .as_deref(), - Some("Qwen3") - ); - assert_eq!( - running_relay_mesh_model_id(&mesh_record("live", None), &active, &personas, &global) - .as_deref(), - Some("Qwen3") - ); - - let mut non_mesh = mesh_record("plain", Some(std::process::id())); - non_mesh.env_vars.clear(); - non_mesh.relay_mesh = None; - assert!(running_relay_mesh_model_id( - &non_mesh, - &active_set(&["plain"]), - &personas, - &global, - ) - .is_none()); - } - #[test] fn recovery_error_sentinel_does_not_match_unrelated_errors() { assert!( diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs index b9c73328aff..fa6307ec8fb 100644 --- a/desktop/src-tauri/src/relay/scope.rs +++ b/desktop/src-tauri/src/relay/scope.rs @@ -41,6 +41,12 @@ impl ScopedWorkspaceRelay { pub fn as_str(&self) -> &str { &self.0 } + + /// Recheck a captured workspace after preflight without substituting a + /// runtime-normalized URL (which may alias distinct tenant authorities). + pub fn revalidate(self, workspace_relay_url: String) -> Result { + bind_expected_relay_scope(Some(self.as_str()), workspace_relay_url) + } } /// Validate a caller-captured relay scope against one workspace-relay read @@ -119,6 +125,30 @@ mod tests { bind_expected_signer, }; + #[test] + fn preflight_revalidation_preserves_localhost_authority_not_runtime_alias() { + let relay = "ws://localhost:3037"; + let captured = bind_expected_relay_scope(None, relay.into()).unwrap(); + let runtime = + crate::managed_agents::ManagedAgentRuntimeKey::new("a".repeat(64), captured.as_str()) + .unwrap(); + assert_eq!(runtime.relay_url, relay); + assert_eq!(captured.revalidate(relay.into()).unwrap().as_str(), relay); + } + + #[test] + fn preflight_revalidation_rejects_switch_even_to_same_runtime_alias() { + for changed in ["ws://127.0.0.1:3037", "wss://other.example"] { + let captured = bind_expected_relay_scope(None, "ws://localhost:3037".into()).unwrap(); + assert!(captured.revalidate(changed.into()).is_err()); + } + assert!(bind_expected_relay_scope( + Some("ws://localhost:3037"), + "ws://127.0.0.1:3037".into(), + ) + .is_err()); + } + #[test] fn matching_scope_passes_across_ws_http_normalization() { assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example") diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b50..8673e6eeb77 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -217,6 +217,10 @@ impl Drop for BlobLockGuard { /// single JSON blob entry (one OS prompt per process lifetime). pub struct SecretStore { service: String, + // Isolated production-boundary fixtures can supply a synthetic backend. + // No OS keyring operation is reachable when this is set. + #[cfg(test)] + test_backend: Option>>>, /// In-memory cache of the deserialized blob. `None` means "not yet loaded". cache: Mutex>>, } @@ -228,6 +232,8 @@ impl SecretStore { pub fn keyring(service: impl Into) -> Self { SecretStore { service: service.into(), + #[cfg(test)] + test_backend: None, cache: Mutex::new(None), } } @@ -474,6 +480,14 @@ impl SecretStore { /// Probe whether `key` exists and whether the backend is reachable. pub fn probe(&self, key: &str) -> KeyringProbe { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + return if backend.lock().unwrap().contains_key(key) { + KeyringProbe::Present + } else { + KeyringProbe::ReachableButEmpty + }; + } #[cfg(feature = "system-keyring")] { match self.load_blob() { @@ -547,6 +561,12 @@ impl SecretStore { /// migration fires when the blob exists but the key is absent, covering /// partial-migration scenarios (e.g. identity migrated first, agents not yet). pub fn load(&self, key: &str) -> Result, String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + let mut cache = self.cache.lock().map_err(|e| e.to_string())?; + let map = cache.get_or_insert_with(|| backend.lock().unwrap().clone()); + return Ok(map.get(key).cloned()); + } #[cfg(feature = "system-keyring")] { match self.load_blob() { @@ -593,6 +613,29 @@ impl SecretStore { } } + /// Read one already-provisioned secret from the backend, bypassing the cache. + /// Launch admission must observe revocation and outages; never migrate or write. + pub(crate) fn load_fresh_readonly(&self, key: &str) -> Result, String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + return Ok(backend.lock().map_err(|e| e.to_string())?.get(key).cloned()); + } + #[cfg(feature = "system-keyring")] + { + let Some(raw) = self.read_blob_raw()? else { + return Ok(None); + }; + let mut map: HashMap = + serde_json::from_slice(&raw).map_err(|_| "Invalid secret store".to_string())?; + Ok(map.remove(key)) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = key; + Err("system-keyring feature disabled".into()) + } + } + /// Insert all entries from `entries` into the blob in a single mutation. /// /// Entries that already exist in the blob are overwritten; entries not @@ -727,6 +770,13 @@ impl SecretStore { /// Store `value` for `key`. Reports `Err` on availability failures — callers /// decide whether to fall back to file storage. pub fn store(&self, key: &str, value: &str) -> Result<(), String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + let mut map = backend.lock().map_err(|e| e.to_string())?; + map.insert(key.into(), value.into()); + *self.cache.lock().map_err(|e| e.to_string())? = Some(map.clone()); + return Ok(()); + } #[cfg(feature = "system-keyring")] { self.mutate_blob(|map| { @@ -930,6 +980,7 @@ mod tests { fn with_cache(service: &str, cache: Option>) -> Self { SecretStore { service: service.to_string(), + test_backend: None, cache: Mutex::new(cache), } } @@ -1304,3 +1355,15 @@ mod tests { assert_eq!(store3.load("agent:abc123").unwrap(), None); } } + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl SecretStore { + /// Synthetic backend with an independent warm cache; never opens a keyring. + pub(crate) fn synthetic(backend: std::sync::Arc>>) -> Self { + Self { + service: "synthetic-agent-credentials".into(), + cache: Mutex::new(None), + test_backend: Some(backend), + } + } +} diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index b1548c69370..c2c7044aa06 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -2,7 +2,7 @@ use tauri::Manager; use crate::app_state::AppState; use crate::managed_agents::{ - self, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, + self, kill_stale_tracked_processes, load_managed_agents, storage::save_runtime_metadata_batch, sync_managed_agent_processes, BackendKind, }; use crate::{prevent_sleep, util}; @@ -263,7 +263,7 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]); if changed { - save_managed_agents(app, &records)?; + save_runtime_metadata_batch(app, &records)?; } Ok(()) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index da0fbf65c49..90cec7b8e74 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,3 +1,4 @@ +import { DesktopListStartup } from "@/features/agents/ui/KnownDesktops"; import { isTauri } from "@tauri-apps/api/core"; import { emit } from "@tauri-apps/api/event"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -261,7 +262,10 @@ function CommunityQueryProvider({ }, [queryClient]); return ( - {children} + + + {children} + ); } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dfb9c0ed494..ad4e45451e2 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -308,6 +308,31 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +## Remote Desktop Stop + +Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop, +not inferred agent location. The app-scoped receiver subscribes live only; +reopening never replays commands. Receiver initialization reports a safe failure +stage without exposing raw transport/IPC exceptions. Transient initialization +failures and transient CLOSED states recover through a bounded receiver-owner +budget; each attempt uses a fresh live-only subscription and repeats +projection-only sync before admission. Terminal closure or exhausted recovery +stays in the scope-owned notification, whose deliberate retry resets the receiver +budget. A known latched-terminal relay session also reports immediately during +initialization without consuming that budget; unknown and transient failures +remain bounded retries. Recovery must discard queued callbacks from the retired +receiver, not retry an operation, and must respect the relay rate-limit gate. +A readiness timeout is unconfirmed delivery, not a failed initialization; late +EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request; +the relay redelivers stored Stop duplicates without repeating relay side effects. +The receiver returns saved results or Unknown, never repeats a consumed Stop. +Native owner-delegation and community checks +precede durable admission and ordinary pair Stop. A delivery ACK is not success. +All local spawn paths consume the durable Stop fence at the shared native +spawn boundary. Only a deliberate **Start agent** action can supersede that +fence; config/restore/reconcile and Restart continuations cannot. Explicit Start +captures its fence before preflight and fails if a newer Stop arrives. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness @@ -389,3 +414,71 @@ matches the code is worse than no rule; a new pattern that isn't written down here will be broken by the next agent that never learns it existed. Reviewers: treat a config-behavior diff without a matching AGENTS.md diff (or an explicit "no rules changed" note) as incomplete. + +## Named runtime configurations + +The Agents-page editor manages one exact agent in an owner + community scope. +Durable configuration sets live on the global agent record keyed by that scope; +IPC reads/replaces only the authorized set and preserves every other scope. +Host is a configuration field, not the scope of the whole global record. +A local editor may preserve another host's entries but cannot edit/delete/select +them. Missing legacy sets mean Default, with the existing inheritance behavior. + +Selection/editing is next-launch state, never a model switch or a running receipt. +Start sends the exact `{id, revision}` (null explicitly means Default). Native +preparation projects immutable launch inputs without persisting over identity or +persona fields, checks local identity access and destination prerequisites, then +revalidates after async readiness and at shared spawn. A stale revision or missing +prerequisite fails rather than falling back. Auto-restart is suppressed in the +active scoped summary while named configurations or a named running launch exist; +saving one scope does not change the global auto-restart preference. + +The editor's running revision comes from the live pair's spawn snapshot, not its +selection. Unavailable choices never remove independent Stop controls. Model IDs +are explicit authored values; credentials are provisioned locally, never entered +or copied by this editor. See `docs/named-runtime-configurations.md` for the native +contract and the distinction between fixture checks and real execution evidence. + +All ordinary launch consumers (including Default, bulk restart, direct pairs and +restore) capture configuration before async provider preflight. Shared native +spawn requires that preflighted plan; it never reselects a configuration. +Recovery reads the actual running pair snapshot, not next-launch selection. +Pair Restart is one native preflight/locked Stop/spawn operation, never frontend +Stop then Start. A refused preflight leaves the old process and turns intact; +a successful Stop followed by failed launch returns an existing Failed status. +The frontend clears only turn IDs captured before that native operation, so new +replacement turns are safe even when its result arrives after they start. + +## Runtime configuration lifecycle consumer + +Known Desktops discovers named choices through owner-private, community+agent+host +scoped Catalog responses, never host inventory or another community's configuration +store. One bounded summary per encrypted response, at most 32 pages per host; +unknown, incomplete and expired readiness cannot offer Start. Mounted options +expire without a manual refresh. This never removes ordinary existing Stop. + +Start binds the exact configuration ID **and revision**, not the destination's +current selection. Switch (including same-host switching) checks the target first, +confirms source Stop, then requests a fresh Start of that exact revision. Preflight +and Catalog cannot write placement intent or stop a process. The native consumer +uses ordinary asynchronous preflight outside the transition lock, revalidates the +immutable plan inside it, and delegates to the shared prepared launcher under the +existing admission/Stop fences. Only explicit Start captures the shared Stop-fence +resume ticket before preflight; probes and Restart never receive that authority. +Shared admission checks the captured runtime generation before destructive Restart, +and a post-Stop Failed status must never become a Running response. No second spawn +path or launch authority is allowed. Missing/edited targets fail rather than +substitute another configuration. Remote Start uses ordinary destination-local +credentials: a matching agent key must already be independently provisioned on the +host. Catalog/preflight and execution recheck that local access; missing, unreadable +or wrong identity excludes launch choices before source Stop. No key transfer, +broker issuer, enrollment or provisioning wizard is part of this flow. Readiness +never hides an existing running process or its ordinary Stop control. + +Running identity comes only from the actual live process snapshot, never the next +selection. A different or unknown running configuration cannot satisfy an explicit +Start. Exact retries preserve signed bytes and saved results. Cancel, scope changes, +and disconnected/retired receivers cannot resume a later destination Start. +Regression seams: desktopLifecycle.test.mjs, mounted DesktopLifecycleControl.test.mjs, +core desktop_lifecycle/protocol_tests.rs and native placement/tests.rs. Mock IPC +passing is not evidence of a native successful launch or two-Desktop switching. diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index ebafb3f1976..36a1c12bee8 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -699,6 +699,26 @@ export function clearActiveTurnsForAgent(agentPubkey: string): void { notifyListeners(); } +/** Capture only this generation's known turns before a native atomic restart. + * Clearing after restart cannot tombstone turns started by the replacement. */ +export function captureActiveTurnsForAgentClear( + agentPubkey: string, +): () => void { + const key = normalizePubkey(agentPubkey); + const captured = [...(activeTurnsByAgent.get(key)?.keys() ?? [])]; + return () => { + const turns = activeTurnsByAgent.get(key); + const agentClockNow = Date.now() - (clockOffsetByAgent.get(key) ?? 0); + for (const turnId of captured) { + recordTerminal(key, turnId, agentClockNow); + turns?.delete(turnId); + } + if (turns?.size === 0) activeTurnsByAgent.delete(key); + invalidateCache(key); + notifyListeners(); + }; +} + /** * Clears all live turn state (active turns, offsets, watermarks, tombstones). * Intentionally preserves `savedByCommunity` — community-switch snapshots diff --git a/desktop/src/features/agents/desktopCapabilities.test.mjs b/desktop/src/features/agents/desktopCapabilities.test.mjs new file mode 100644 index 00000000000..da09f6bb4a8 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { refreshDesktopCapabilities } from "./desktopCapabilities.ts"; +import { DesktopListView } from "./ui/KnownDesktops.tsx"; + +const scope = { owner: "owner-a", community: "wss://one.example" }; +const event = { id: "signed", created_at: 100, kind: 30182 }; +const row = { + id: "desktop-a", + reported: 100, + runtimes: [ + { + id: "goose", + availability: "cli_missing", + requires_external_cli: true, + max_parallelism: null, + }, + ], +}; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const finish = (name, result) => { + if (name === boundary) epoch++; + return result; + }; + const f = { + calls, + head: [], + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_capabilities") + return finish("prepare", { event }); + assert.equal(command, "read_desktop_capabilities"); + return finish( + "read", + args.events.map(() => row), + ); + }, + relay: { + getSessionEpoch: () => epoch, + fetchEvents: async (filter) => { + assert.deepEqual(filter.authors, [scope.owner]); + assert.deepEqual(filter.kinds, [30182]); + assert.ok(filter.limit <= 100); + return finish("fetch", filter["#d"] ? f.head : [event]); + }, + publishEvent: async (value, _timeout, _failure, check) => { + finish("transport"); + check(); // Delayed transport must invoke the production cancellation guard. + calls.push(value); + f.head = [value]; + finish("ack"); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopCapabilities(scope, active, f.ipc, f.relay); + return f; +} + +test("unchanged accepted report does not republish; failed publish retries exact bytes", async () => { + const f = fixture(); + assert.deepEqual((await f.refresh()).rows, [row]); + await f.refresh(); + assert.equal(f.calls.filter((c) => c === event).length, 1); + f.head = []; + f.relay.publishEvent = async (value) => { + assert.equal(value, event); + throw Error("offline"); + }; + for (let i = 0; i < 2; i++) assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => { + throw Error("unavailable"); + }; + await assert.rejects(f.refresh(), /unavailable/); +}); + +test("deferred preparation settles with prior relay facts, never publishes, and honors cancellation", async () => { + const f = fixture(); + const ipc = f.ipc; + let active = true; + let cancel = false; + f.ipc = async (command, args) => { + if (command === "prepare_desktop_capabilities") { + if (cancel) active = false; + throw Error("Desktop capability facts deferred until the clock advances"); + } + return ipc(command, args); + }; + const deferred = await f.refresh(() => active); + assert.deepEqual(deferred.rows, [row]); + assert.match(deferred.warning, /Will retry/); + assert.ok(!f.calls.includes(event)); + cancel = true; + await assert.rejects( + f.refresh(() => active), + /scope changed/, + ); + assert.ok(!f.calls.includes(event)); + // A later, active attempt prepares afresh; no held promise or queued event. + f.ipc = ipc; + assert.equal((await f.refresh()).warning, ""); + assert.equal(f.calls.filter((c) => c === event).length, 1); +}); + +test("all async boundaries fence cancellation, account/community switches and late ACK", async () => { + for (const boundary of ["prepare", "read", "fetch", "transport", "ack"]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary !== "ack") assert.ok(!f.calls.includes(event)); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); + f.relay.fetchEvents = async () => Array(100).fill(event); + assert.equal((await f.refresh()).partial, true); + f.ipc = async () => { + throw Error("invalid signature"); + }; + await assert.rejects(f.refresh(), /invalid signature/); +}); + +test("mounted Desktop rows show exact remote facts and unknowns, not readiness", () => { + const html = renderToStaticMarkup( + React.createElement(DesktopListView, { + list: { + rows: ["desktop-a", "desktop-b"].map((id) => ({ + id, + name: id, + updated: 1, + })), + local: "desktop-b", + }, + capabilities: [row], + now: 99, + refresh() {}, + loading: false, + error: false, + }), + ); + assert.equal( + (html.match(/Capability details<\/summary>/g) ?? []).length, + 2, + ); + for (const text of [ + "goose", + "cli missing", + "not configured", + "Desktop clock ahead", + "No capability report received", + "not agent readiness", + "Settings", + ]) + assert.ok(html.includes(text), text); +}); diff --git a/desktop/src/features/agents/desktopCapabilities.ts b/desktop/src/features/agents/desktopCapabilities.ts new file mode 100644 index 00000000000..566ef2b8117 --- /dev/null +++ b/desktop/src/features/agents/desktopCapabilities.ts @@ -0,0 +1,85 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQuery } from "@tanstack/react-query"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export type DesktopCapabilities = { + id: string; + reported: number; + runtimes: { + id: string; + availability: string; + requires_external_cli: boolean; + max_parallelism: number | null; + }[]; +}; + +/** Exact signed reports are persisted natively; only changed facts create new bytes. */ +export async function refreshDesktopCapabilities( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop capability scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + const read = (events: RelayEvent[]) => + wait( + ipc("read_desktop_capabilities", { + ...scope, + events, + }), + ); + const filter = { kinds: [30182], authors: [scope.owner] }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_capabilities", scope), + ); + const [local] = await read([event]); + const head = await wait( + relay.fetchEvents({ ...filter, "#d": [local.id], limit: 1 }), + ); + await read(head); + if (!head.some((e) => e.id === event.id)) + await wait( + relay.publishEvent( + event, + "Desktop report timed out", + "Desktop report failed", + check, + ), + ); + } catch { + check(); + warning = + "This Desktop could not synchronize capability facts. Will retry."; + } + const events = await wait(relay.fetchEvents({ ...filter, limit: 100 })); + return { rows: await read(events), partial: events.length === 100, warning }; +} + +export function useDesktopCapabilities(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-capabilities", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopCapabilities(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }); +} diff --git a/desktop/src/features/agents/desktopLifecycle.test.mjs b/desktop/src/features/agents/desktopLifecycle.test.mjs new file mode 100644 index 00000000000..ea16f9e1243 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycle.test.mjs @@ -0,0 +1,585 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { lifecycleClient, receiveLifecycle } from "./desktopLifecycle.ts"; +const scope = { owner: "owner", community: "wss://one.example" }; +const configuration = { id: "config", revision: "revision-1" }; +const ready = () => ({ + outcome: "ready", + observation: { valid_until: Math.floor(Date.now() / 1000) + 30 }, +}); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function fixture() { + let epoch = 0, + connection = 1, + active = true, + live; + let placement = ["source", "selection"], + stopOutcome = "stopped"; + let lifecycleOutcome = "running", + history = [], + ackLost = false; + const prepared = [], + sent = [], + calls = [], + errors = []; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push([command, args]); + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") return placement; + if ( + command === "prepare_desktop_lifecycle" || + command === "prepare_desktop_stop" + ) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return args.request.action === "preflight" + ? ready() + : { + outcome: + args.request.action === "status" ? "running" : lifecycleOutcome, + }; + if (command === "read_desktop_stop_results") return stopOutcome; + if (command === "receive_desktop_lifecycle") + return { id: "result", kind: 50183 }; + throw Error(command); + }; + const relay = { + getSessionEpoch: () => epoch, + getConnectionGeneration: () => connection, + fetchEvents: async (filter) => + filter.kinds.includes(50182) ? history : [], + publishEvent: async (event, _timeout, _failure, check) => { + check(); + sent.push(event); + if (ackLost) throw Error("ACK lost"); + }, + subscribeLive: async (filter, callback) => { + assert.deepEqual(filter, { + kinds: [50182, 50180], + authors: [scope.owner], + limit: 0, + }); + live = callback; + return () => { + live = undefined; + }; + }, + }; + return { + ipc, + relay, + prepared, + sent, + calls, + errors, + client: () => lifecycleClient(scope, () => active, ipc, relay), + changeScope: () => { + epoch++; + }, + disconnect: () => { + connection++; + }, + unmount: () => { + active = false; + }, + stop: (value) => { + stopOutcome = value; + }, + outcome: (value) => { + lifecycleOutcome = value; + }, + place: (value) => { + placement = value; + }, + history: (value) => { + history = value; + }, + loseAck: () => { + ackLost = true; + }, + deliver: (event) => live?.(event), + }; +} + +test("lost ACK/result permits explicit exact-byte retry, not a fresh Start", async () => { + const f = fixture(), + client = f.client(); + const request = await client.start("destination", "agent", configuration); + f.loseAck(); + f.outcome("unknown"); + assert.equal(await client.send(request, 0), "unknown"); + f.outcome("running"); + assert.equal(await client.send(request, 1), "running"); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 1); + assert.deepEqual( + f.sent.filter((r) => r.action === "start"), + [request, request], + ); + assert.equal(f.sent.at(-2), f.sent.at(-1)); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); +}); + +for (const interruption of ["changeScope", "disconnect", "unmount"]) { + test(`${interruption} during Stop cancels Move before destination prepare or send`, async () => { + const f = fixture(), + client = f.client(); + const publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) f[interruption](); + }; + await assert.rejects( + client.move("agent", "destination", ["source"], () => {}, configuration), + /scope changed/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); + }); +} + +test("failed Stop is final even if a successful outcome appears later", async () => { + const f = fixture(); + f.stop("failed"); + await assert.rejects( + f + .client() + .move("agent", "destination", ["source"], () => {}, configuration), + /will not continue later/, + ); + f.stop("stopped"); + await tick(); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); +}); + +test("unconfirmed Stop exhausts polling without storing any future Start", async () => { + const f = fixture(); + f.stop("unknown"); + const timer = globalThis.setTimeout; + globalThis.setTimeout = (fn) => { + queueMicrotask(fn); + return 0; + }; + try { + await assert.rejects( + f + .client() + .move("agent", "destination", ["source"], () => {}, configuration), + /Could not confirm source Stop/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); + } finally { + globalThis.setTimeout = timer; + } +}); + +test("Move dispatches Start only after Stop success and unchanged placement", async () => { + const f = fixture(); + assert.equal( + await f + .client() + .move("agent", "destination", ["source"], () => {}, configuration), + "running", + ); + assert.deepEqual( + f.sent.map((r) => r.action ?? "stop"), + ["preflight", "status", "stop", "start"], + ); + assert.equal(f.sent.at(-1).desktop, "destination"); + const stopRead = f.calls.findIndex( + ([c]) => c === "read_desktop_stop_results", + ); + const startPrepare = f.calls.findIndex( + ([c, a]) => c === "prepare_desktop_lifecycle" && a.action === "start", + ); + assert.ok(stopRead < startPrepare); +}); + +test("another Desktop's new placement supersedes an in-flight Move", async () => { + const f = fixture(), + publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) f.place(["third", "new-selection"]); + }; + await assert.rejects( + f + .client() + .move("agent", "destination", ["source"], () => {}, configuration), + /Placement changed/, + ); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); +}); + +test("same-host different configuration switches through preflight, Stop, then exact revision Start", async () => { + const f = fixture(); + assert.equal( + await f + .client() + .move("agent", "source", ["source"], () => {}, configuration), + "running", + ); + assert.deepEqual( + f.sent.map((r) => r.action ?? "stop"), + ["preflight", "status", "stop", "start"], + ); + assert.deepEqual(f.sent.at(-1).configuration, configuration); +}); + +test("receiver projects history without executing it and invalidates live work on disconnect", async () => { + const f = fixture(); + f.history([{ id: "historical", kind: 50182 }]); + const close = await receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + ); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + f.deliver({ id: "live", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + f.disconnect(); + f.deliver({ id: "late", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + assert.equal(f.errors.length, 1); + close(); +}); + +for (const stage of [ + "subscription", + "history", + "projection", + "reconciliation", +]) { + test(`receiver reports safe ${stage} failure and never admits queued work`, async () => { + const f = fixture(); + const secret = "private key /home/private bearer secret"; + if (stage === "subscription") + f.relay.subscribeLive = async () => { + throw Error(secret); + }; + if (stage === "history") + f.relay.fetchEvents = async () => { + throw Error(secret); + }; + const ipc = async (command, args) => { + if ( + command === "observe_desktop_placement" && + ((stage === "projection" && !args.reconcile) || + (stage === "reconciliation" && args.reconcile)) + ) + throw Error(secret); + return f.ipc(command, args); + }; + const started = receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + ipc, + f.relay, + ); + f.deliver({ id: "queued", kind: 50182 }); + await assert.rejects(started, (error) => { + assert.match(error.message, new RegExp(`${stage}: request failed`)); + assert.doesNotMatch(error.message, /private|bearer|secret/); + return true; + }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + assert.deepEqual( + f.errors, + [], + "discarded callbacks cannot replace the startup diagnosis", + ); + }); +} + +test("explicit receiver recovery discards old queued work and projects history without replay", async () => { + const f = fixture(); + let rejectHistory; + f.relay.fetchEvents = () => + new Promise((_, reject) => { + rejectHistory = reject; + }); + const failed = receiveLifecycle( + scope, + () => true, + () => {}, + f.ipc, + f.relay, + ); + await tick(); + f.deliver({ id: "old-live", kind: 50182 }); + rejectHistory(Error("Timed out while loading channel history.")); + await assert.rejects(failed, /history: history timed out/); + f.relay.fetchEvents = async () => [{ id: "old-live", kind: 50182 }]; + const close = await receiveLifecycle( + scope, + () => true, + () => {}, + f.ipc, + f.relay, + ); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + f.deliver({ id: "new-live", kind: 50182 }); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); + f.deliver({ id: "closed-queue", kind: 50182 }); + close(); + await tick(); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 1, + ); +}); + +test("readiness timeout is distinct, late EOSE recovers, CLOSED retires old callbacks", async () => { + const f = fixture(); + let notify, deliver; + let closed = 0, + ready = 0; + f.relay.subscribeLive = async ( + _filter, + event, + _onReady, + timeout, + options, + ) => { + assert.equal(timeout, 5000); + deliver = event; + assert.equal(options.closedRecovery, "explicit"); + notify = options.onState; + notify("timeout"); + return () => { + closed++; + }; + }; + const close = await receiveLifecycle( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + () => ready++, + ); + assert.match(f.errors[0], /readiness timed out/); + assert.equal(ready, 0); + notify("eose"); + assert.equal(ready, 1); + notify("closed"); + deliver({ id: "late", kind: 50182 }); + await tick(); + assert.equal(closed, 1); + assert.equal( + f.calls.filter(([c]) => c === "receive_desktop_lifecycle").length, + 0, + ); + assert.match(f.errors.at(-1), /subscription closed/); + close(); +}); + +for (const interruption of ["changeScope", "disconnect", "unmount"]) { + test(`${interruption} during preflight forbids Stop and Start even after a late ready result`, async () => { + const f = fixture(); + const ipc = async (command, args) => { + const result = await f.ipc(command, args); + if ( + command === "read_desktop_lifecycle_results" && + args.request.action === "preflight" + ) + f[interruption](); + return result; + }; + const client = lifecycleClient(scope, () => true, ipc, f.relay); + // Unmount is represented by the same captured validity callback as the mounted control. + if (interruption === "unmount") { + let active = true; + const unmountIpc = async (command, args) => { + const result = await f.ipc(command, args); + if (command === "read_desktop_lifecycle_results") active = false; + return result; + }; + await assert.rejects( + lifecycleClient(scope, () => active, unmountIpc, f.relay).move( + "agent", + "source", + ["source"], + () => {}, + configuration, + ), + ); + } else + await assert.rejects( + client.move("agent", "source", ["source"], () => {}, configuration), + ); + assert.equal( + f.prepared.filter((r) => r.kind === 50180 || r.action === "start").length, + 0, + ); + }); +} +for (const result of [ + { outcome: "ineligible" }, + { outcome: "ready" }, + { outcome: "ready", observation: { valid_until: 1 } }, +]) { + test(`failed/missing/expired preflight ${JSON.stringify(result)} has no placement or Stop effects`, async () => { + const f = fixture(); + const ipc = (command, args) => + command === "read_desktop_lifecycle_results" + ? result + : f.ipc(command, args); + await assert.rejects( + lifecycleClient(scope, () => true, ipc, f.relay).move( + "agent", + "source", + ["source"], + () => {}, + configuration, + ), + ); + assert.deepEqual( + f.sent.map((r) => r.action), + ["preflight"], + ); + assert.equal( + f.calls.filter(([c]) => c === "observe_desktop_placement").length, + 0, + ); + }); +} +test("readiness expiring during source Stop cannot authorize a later Start", async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: Date.now() }); + const f = fixture(), + publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) t.mock.timers.tick(31_000); + }; + await assert.rejects( + f.client().move("agent", "source", ["source"], () => {}, configuration), + /readiness expired/, + ); + assert.equal(f.sent.filter((r) => r.kind === 50180).length, 1); + assert.equal(f.prepared.filter((r) => r.action === "start").length, 0); +}); + +test("configuration edits during Stop cannot substitute the target revision", async () => { + const f = fixture(), + target = { ...configuration }, + publish = f.relay.publishEvent; + f.relay.publishEvent = async (...args) => { + await publish(...args); + if (args[0].kind === 50180) { + target.revision = "edited"; + f.outcome("ineligible"); + } + }; + assert.equal( + await f.client().move("agent", "source", ["source"], () => {}, target), + "ineligible", + ); + assert.deepEqual(f.sent.at(-1).configuration, configuration); +}); + +for (const invalid of ["scope", "cursor", "loop", "incomplete", "expired"]) { + test(`catalog rejects ${invalid} without publishing placement`, async () => { + const f = fixture(); + let page = 0; + const ipc = (command, args) => { + if (command !== "read_desktop_lifecycle_results") + return f.ipc(command, args); + const id = String(++page).padStart(3, "0"); + return { + outcome: "ready", + observation: { + valid_until: invalid === "expired" ? 1 : Date.now() / 1000 + 30, + catalog: { + entry: { + configuration: { + id: invalid === "loop" ? "001" : id, + revision: "r", + }, + host: invalid === "scope" ? "foreign" : "destination", + eligible: true, + }, + next: invalid === "cursor" ? "wrong" : id, + }, + }, + }; + }; + await assert.rejects( + lifecycleClient(scope, () => true, ipc, f.relay).catalog("agent", [ + "destination", + ]), + ); + assert.ok(page <= 32); + assert.equal( + f.calls.filter(([c]) => c === "observe_desktop_placement").length, + 0, + ); + assert.ok(f.sent.every((r) => r.action === "catalog")); + }); +} +test("catalog includes only positive readiness and exact expiry, never inventory", async () => { + const f = fixture(); + const validUntil = Math.floor(Date.now() / 1000) + 30; + const ipc = (command, args) => + command !== "read_desktop_lifecycle_results" + ? f.ipc(command, args) + : { + outcome: "ready", + observation: { + valid_until: validUntil, + catalog: { + entry: { + configuration, + host: args.request.desktop, + eligible: args.request.desktop === "ready", + }, + next: null, + }, + }, + }; + const choices = await lifecycleClient( + scope, + () => true, + ipc, + f.relay, + ).catalog("agent", ["ready", "unknown"]); + assert.deepEqual(choices, [ + { configuration, host: "ready", eligible: true, validUntil }, + ]); +}); diff --git a/desktop/src/features/agents/desktopLifecycle.ts b/desktop/src/features/agents/desktopLifecycle.ts new file mode 100644 index 00000000000..b91b1a781fa --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycle.ts @@ -0,0 +1,547 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; +import { + DESKTOP_STOP, + prepareStop, + readStopOutcome, + sendStop, +} from "./desktopStop"; + +import { + LifecycleReceiverError, + receiverStep, +} from "./desktopLifecycleDiagnostics"; + +export const DESKTOP_LIFECYCLE = 50182; +export const DESKTOP_LIFECYCLE_RESULT = 50183; +export type LifecycleAction = + | "start" + | "restart" + | "status" + | "catalog" + | "preflight"; +/** Wire projection of buzz-core's canonical reference; never launch settings. */ +export type RuntimeConfigurationRef = { id: string; revision: string }; +export type RuntimeConfigurationSummary = { + configuration: RuntimeConfigurationRef; + name: string; + host: string; + runtime: string; + model: string; + provider: string | null; + eligible: boolean; +}; +export type ConfigurationChoice = RuntimeConfigurationSummary & { + validUntil: number; +}; +export type LifecycleResult = { + outcome: LifecycleOutcome; + observation?: { + valid_until: number; + running_configuration: RuntimeConfigurationRef | null; + catalog: { + entry: RuntimeConfigurationSummary | null; + next: string | null; + } | null; + } | null; +}; +export type LifecycleOutcome = + | "running" + | "stopped" + | "provisioning_unavailable" + | "failed" + | "unknown" + | "ready" + | "ineligible" + | "different_configuration"; +export type CurrentHost = { + desktop: string; + observation: string; + configuration: RuntimeConfigurationRef | null; +}; + +/** Captures identity and connection generation across every asynchronous step. */ +export function lifecycleClient( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const connection = relay.getConnectionGeneration(); + const check = () => { + if ( + !active() || + relay.getSessionEpoch() !== epoch || + relay.getConnectionGeneration() !== connection + ) + throw new Error("Desktop lifecycle scope changed"); + }; + const prepare = async ( + desktop: string, + agent: string, + action: LifecycleAction, + observed: string | null = null, + configuration: RuntimeConfigurationRef | null = null, + cursor: string | null = null, + ) => { + check(); + const request = await ipc("prepare_desktop_lifecycle", { + ...scope, + desktop, + agent, + action, + observed, + configuration, + cursor, + }); + check(); + return request; + }; + const read = async (request: RelayEvent) => { + check(); + const events = await relay.fetchEvents({ + kinds: [DESKTOP_LIFECYCLE_RESULT], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + check(); + const outcome = await ipc( + "read_desktop_lifecycle_results", + { ...scope, request, events }, + ); + check(); + return outcome ?? { outcome: "unknown" as const }; + }; + const sendResult = async ( + request: RelayEvent, + attempts = 15, + ): Promise => { + check(); + try { + await relay.publishEvent( + request, + "Delivery unconfirmed", + "Delivery failed", + check, + ); + } catch { + check(); /* Lost ACK may still have a signed result. */ + } + for (let i = 0; i < attempts; i++) { + check(); + const outcome = await read(request); + check(); + if (outcome.outcome !== "unknown") return outcome; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return { outcome: "unknown" }; + }; + const send = async (request: RelayEvent, attempts = 15) => + (await sendResult(request, attempts)).outcome; + const fresh = (result: LifecycleResult) => { + check(); + if ( + !result.observation || + result.observation.valid_until <= Date.now() / 1000 + ) + throw new Error( + "Configuration readiness expired or is unknown; check again.", + ); + }; + const preflight = async ( + desktop: string, + agent: string, + configuration: RuntimeConfigurationRef, + ) => { + const request = await prepare(desktop, agent, "preflight", null, { + ...configuration, + }); + const result = await sendResult(request, 3); + if (result.outcome !== "ready") + throw new Error( + "Target configuration is not eligible. No source Stop was requested.", + ); + fresh(result); + return result; + }; + const catalog = async (agent: string, desktops: string[]) => { + const hosts = [...new Set(desktops)]; + if (hosts.length > 32) throw new Error("Too many destination Desktops"); + return ( + await Promise.all( + hosts.map(async (host) => { + const entries: ConfigurationChoice[] = []; + let cursor: string | null = null; + const seen = new Set(); + for (let page = 0; page < 32; page++) { + const request = await prepare( + host, + agent, + "catalog", + null, + null, + cursor, + ); + const result = await sendResult(request, 3); + check(); + if (result.outcome !== "ready") return []; + fresh(result); + const data = result.observation?.catalog; + if (!data || !result.observation) return []; + if ( + data.entry && + (data.entry.host !== host || + (cursor !== null && data.entry.configuration.id <= cursor)) + ) + throw new Error( + "Configuration catalog scope or ordering changed", + ); + if (data.next && data.next !== data.entry?.configuration.id) + throw new Error( + "Configuration catalog cursor does not match its entry", + ); + if (data.entry?.eligible) + entries.push({ + ...data.entry, + validUntil: result.observation.valid_until, + }); + if (!data.next) { + if ( + entries.some((entry) => entry.validUntil <= Date.now() / 1000) + ) + throw new Error("Configuration catalog expired; check again"); + return entries; + } + if (seen.has(data.next)) + throw new Error("Configuration catalog cursor did not advance"); + seen.add(data.next); + cursor = data.next; + } + throw new Error("Configuration catalog is incomplete; check again"); + }), + ) + ).flat(); + }; + const sync = async () => { + let until: number | undefined; + let before_id: string | undefined; + for (let page = 0; page < 64; page++) { + check(); + const events = await receiverStep("history", () => + relay.fetchEvents({ + kinds: [DESKTOP_STOP, DESKTOP_LIFECYCLE], + authors: [scope.owner], + limit: 256, + until, + before_id, + }), + ); + check(); + // No effects while a partial page could still hide a dominating Start. + await receiverStep("projection", () => + ipc("observe_desktop_placement", { + ...scope, + events, + reconcile: false, + }), + ); + check(); + if (events.length < 256) { + await receiverStep("reconciliation", () => + ipc("observe_desktop_placement", { + ...scope, + events: [], + reconcile: true, + }), + ); + check(); + return; + } + const last = events.at(-1); + if (!last || last.id === before_id) + throw new Error("Placement history cursor did not advance"); + until = last.created_at; + before_id = last.id; + } + throw new Error( + "Placement history is incomplete; no launch was dispatched", + ); + }; + const inspect = async (agent: string, desktops: string[]) => { + const hosts = [...new Set(desktops)]; + if (hosts.length > 32) throw new Error("Too many destination Desktops"); + return Promise.all( + hosts.map(async (desktop) => { + const request = await prepare(desktop, agent, "status"); + const result = await sendResult(request, 3); + check(); + return { + desktop, + observation: request.id, + outcome: result.outcome, + configuration: result.observation?.running_configuration ?? null, + }; + }), + ); + }; + const current = async ( + agent: string, + desktops: string[], + ): Promise => { + await sync(); + check(); + const desired = await ipc<[string, string] | null>( + "read_desktop_placement", + { ...scope, agent }, + ); + check(); + // Probe actual native state, never infer current from last-heard/profile. + const candidates = desired ? [desired[0]] : [...new Set(desktops)]; + if (!candidates.length || candidates.length > 32) + throw new Error("Current Desktop is unknown"); + const observations = await inspect(agent, candidates); + check(); + const running = observations.filter((o) => o.outcome === "running"); + if ( + running.length !== 1 || + observations.some( + (o) => o.outcome !== "running" && o.outcome !== "stopped", + ) + ) + throw new Error( + "Current Desktop is unknown or ambiguous; choose explicit Start instead", + ); + return running[0]; + }; + const start = async ( + desktop: string, + agent: string, + configuration: RuntimeConfigurationRef, + ) => { + const reference = { ...configuration }; + const readiness = await preflight(desktop, agent, reference); + await sync(); + fresh(readiness); + return prepare(desktop, agent, "start", null, reference); + }; + /** Failed/unconfirmed Move is terminal in this invocation. No saved future + * Start, background callback, reopen replay or retry of a failed Move. */ + const move = async ( + agent: string, + destination: string, + desktops: string[], + onStage: (stage: string) => void, + configuration: RuntimeConfigurationRef, + ): Promise => { + const reference = { ...configuration }; + const readiness = await preflight(destination, agent, reference); + const host = await current(agent, desktops); + check(); + const before = await ipc<[string, string] | null>( + "read_desktop_placement", + { ...scope, agent }, + ); + check(); + fresh(readiness); + const stop = await prepareStop( + scope, + host.desktop, + agent, + active, + ipc, + relay, + ); + check(); + onStage( + "Waiting for source Desktop Stop; destination has not been started.", + ); + try { + await sendStop(scope, stop, active, relay); + } catch { + check(); + } + let stopped = false; + for (let i = 0; i < 15; i++) { + const result = await readStopOutcome(scope, stop, active, ipc, relay); + check(); + if (result === "failed") break; + if (result === "stopped") { + stopped = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!stopped) + throw new Error( + "Could not confirm source Stop; destination was not started. This Move will not continue later.", + ); + await sync(); + check(); + const after = await ipc<[string, string] | null>("read_desktop_placement", { + ...scope, + agent, + }); + check(); + // Stop may clear source, but another device's new placement must win. + if (after && (!before || after[1] !== before[1])) + throw new Error( + "Placement changed during Move; destination was not started", + ); + onStage("Source Stop confirmed. Requesting destination Start."); + fresh(readiness); + const request = await prepare(destination, agent, "start", null, reference); + check(); + return send(request); + }; + return { + check, + prepare, + read, + send, + sync, + current, + inspect, + start, + move, + catalog, + preflight, + }; +} + +/** Subscribe first, then project history; live commands wait for complete + * initialization. Explicit receiver retry starts a fresh live-only subscription, + * never retries an operation or executes historical commands. */ +export async function receiveLifecycle( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + ipc = invoke, + relay = relayClient, + onReady: () => void = () => {}, + onClosed?: (recovery: LiveSubscriptionClosedRecovery) => void, +) { + let stopped = false; + let released = false; + let stopSubscription = () => {}; + let synced = false; + let subscriptionReady = false; + const valid = () => active() && !stopped; + let client: ReturnType; + let initialized: () => void = () => {}; + const ready = new Promise((resolve) => { + initialized = resolve; + }); + let chain = Promise.resolve(); + let pending = 0; + const unsubscribe = await receiverStep("subscription", () => + relay.subscribeLive( + { + kinds: [DESKTOP_LIFECYCLE, DESKTOP_STOP], + authors: [scope.owner], + limit: 0, + }, + (event) => { + if (!valid()) return; + if (pending >= 16) { + onError( + "Desktop lifecycle receiver is busy; outcome is unconfirmed.", + ); + return; + } + pending++; + chain = chain + .then(async () => { + await ready; + if (!valid()) return; + client.check(); + await ipc("observe_desktop_placement", { + ...scope, + events: [event], + reconcile: true, + }); + client.check(); + const result = await ipc( + event.kind === DESKTOP_STOP + ? "receive_desktop_stop" + : "receive_desktop_lifecycle", + { ...scope, event }, + ); + client.check(); + if (result) + await relay.publishEvent( + result, + "Result delivery unconfirmed", + "Result delivery failed", + client.check, + ); + }) + .catch(() => { + if (valid()) + onError( + "Desktop lifecycle result is unconfirmed. No automatic operation retry.", + ); + }) + .finally(() => { + pending--; + }); + }, + undefined, + 5000, + { + closedRecovery: "explicit", + onState: (readiness, closed) => { + if (!valid()) return; + subscriptionReady = readiness === "eose"; + if (readiness === "closed") { + stopped = true; + stopSubscription(); + if (onClosed) + onClosed( + closed ?? { classification: "terminal", retryAfterMs: 0 }, + ); + else + onError( + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", + ); + } else if (readiness === "timeout") { + onError( + "Desktop lifecycle subscription readiness timed out. Delivery is unconfirmed.", + ); + } else if (synced) onReady(); + }, + }, + ), + ); + const close = () => { + if (released) return; + released = true; + stopped = true; + // Unsubscribe may fail on a dead socket; it must not revive this receiver + // or leave an unhandled promise. A retry always owns a new subscription. + void Promise.resolve() + .then(unsubscribe) + .catch(() => {}); + }; + stopSubscription = close; + client = lifecycleClient(scope, valid, ipc, relay); + try { + if (stopped) throw new LifecycleReceiverError("subscription", "closed"); + await client.sync(); + client.check(); + synced = true; + initialized(); + if (subscriptionReady) onReady(); + } catch (error) { + close(); + initialized(); + throw error instanceof LifecycleReceiverError + ? error + : new LifecycleReceiverError("initialization", error); + } + return close; +} diff --git a/desktop/src/features/agents/desktopLifecycleDiagnostics.ts b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts new file mode 100644 index 00000000000..cee8e60769e --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleDiagnostics.ts @@ -0,0 +1,50 @@ +/** Safe receiver diagnostics: raw IPC/transport errors can contain private data. */ +type Stage = + | "initialization" + | "subscription" + | "history" + | "projection" + | "reconciliation"; + +export type LifecycleReceiverFailureClassification = "retryable" | "terminal"; + +export class LifecycleReceiverError extends Error { + readonly recoveryClassification: LifecycleReceiverFailureClassification; + + constructor(stage: Stage, error: unknown) { + const value = error instanceof Error ? error.message : error; + const recoveryClassification = + value === "Relay session is terminal; cannot reconnect." + ? "terminal" + : "retryable"; + const reason = + value === "closed" + ? "subscription closed" + : value === "Desktop lifecycle scope changed" + ? "scope changed" + : value === "Relay session is terminal; cannot reconnect." + ? "relay session requires reconnection" + : value === "Timed out while loading channel history." + ? "history timed out" + : "request failed"; + super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`); + this.recoveryClassification = recoveryClassification; + } +} + +export function receiverErrorMessage(error: unknown): string { + return error instanceof LifecycleReceiverError + ? error.message + : "Desktop lifecycle receiver is unavailable (initialization failed)."; +} + +export async function receiverStep( + stage: Stage, + action: () => Promise, +): Promise { + try { + return await action(); + } catch (error) { + throw new LifecycleReceiverError(stage, error); + } +} diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs new file mode 100644 index 00000000000..6948a55ca65 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleReceiver.test.mjs @@ -0,0 +1,487 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { receiveLifecycle } from "./desktopLifecycle.ts"; +import { + ownLifecycleReceiver, + RECEIVER_RECOVERY_DELAYS_MS, +} from "./desktopLifecycleReceiver.ts"; + +const scope = { owner: "owner", community: "wss://one.example" }; + +async function flushUntil(predicate, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail("condition did not become true before the microtask limit"); +} + +function timers() { + let nextId = 1; + const pending = new Map(); + return { + setTimer(callback, delayMs) { + const id = nextId++; + pending.set(id, { callback, delayMs }); + return id; + }, + clearTimer(id) { + pending.delete(id); + }, + fireNext() { + const entry = pending.entries().next().value; + assert.ok(entry, "expected a pending recovery timer"); + pending.delete(entry[0]); + entry[1].callback(); + }, + pending, + }; +} + +test("first subscribe failure recovers without a reconnect callback and syncs before admission", async () => { + const clock = timers(); + let subscribeCalls = 0; + let liveEvent; + let activeSubscriptions = 0; + let closed = 0; + let ready = 0; + const calls = []; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (filter, onEvent, _onReady, _timeout, options) => { + assert.deepEqual(filter, { + kinds: [50182, 50180], + authors: [scope.owner], + limit: 0, + }); + subscribeCalls++; + if (subscribeCalls === 1) + throw new Error("fixture first connection rejected"); + activeSubscriptions++; + liveEvent = onEvent; + onEvent({ id: "during-sync", kind: 50182, created_at: 1 }); + options.onState("eose"); + return () => { + activeSubscriptions--; + closed++; + }; + }, + fetchEvents: async () => { + calls.push("history"); + return []; + }, + publishEvent: async () => {}, + }; + const ipc = async (command, args) => { + if (command === "observe_desktop_placement") { + calls.push(args.reconcile ? `projection:${args.events.length}` : "page"); + return; + } + if (command === "receive_desktop_lifecycle") { + calls.push("admission"); + return null; + } + throw new Error(command); + }; + const errors = []; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => ready++, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + ipc, + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => clock.pending.size === 1); + assert.equal(subscribeCalls, 1); + assert.deepEqual(errors, []); + assert.equal(clock.pending.values().next().value.delayMs, 1_000); + + clock.fireNext(); + await flushUntil(() => calls.includes("admission")); + assert.equal(subscribeCalls, 2); + assert.equal(activeSubscriptions, 1); + assert.equal(ready, 1); + assert.deepEqual(calls, [ + "history", + "page", + "projection:0", + "projection:1", + "admission", + ]); + + liveEvent({ id: "ordinary-live", kind: 50182, created_at: 2 }); + await flushUntil( + () => calls.filter((call) => call === "admission").length === 2, + ); + stop(); + await flushUntil(() => activeSubscriptions === 0); + assert.equal(activeSubscriptions, 0); + assert.equal(closed, 1); +}); + +test("terminal relay-session initialization failure does not consume the recovery budget", async () => { + const clock = timers(); + let subscribeCalls = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async () => { + subscribeCalls++; + throw new Error("Relay session is terminal; cannot reconnect."); + }, + fetchEvents: async () => assert.fail("terminal session must not sync"), + publishEvent: async () => {}, + }; + const errors = []; + ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => + assert.fail("terminal session must not invoke native IPC"), + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => errors.length === 1); + assert.equal(subscribeCalls, 1); + assert.equal(clock.pending.size, 0); + assert.equal( + errors[0], + "Desktop lifecycle receiver is unavailable (subscription: relay session requires reconnection).", + ); +}); + +test("transient CLOSED before readiness retires and replaces the whole receiver", async () => { + const clock = timers(); + let subscribeCalls = 0; + let closeCount = 0; + let ready = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (_filter, _onEvent, _onReady, _timeout, options) => { + subscribeCalls++; + if (subscribeCalls === 1) + options.onState("closed", { + classification: "retryable", + retryAfterMs: 0, + }); + else options.onState("eose"); + return () => closeCount++; + }, + fetchEvents: async () => [], + publishEvent: async () => {}, + }; + const stop = ownLifecycleReceiver( + scope, + () => {}, + () => ready++, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => {}, + relay, + onReady, + onClosed, + ), + }, + ); + + await flushUntil(() => clock.pending.size === 1); + await flushUntil(() => closeCount === 1); + assert.equal(subscribeCalls, 1); + assert.equal(closeCount, 1); + clock.fireNext(); + await flushUntil(() => ready === 1); + assert.equal(subscribeCalls, 2); + stop(); + await flushUntil(() => closeCount === 2); +}); + +test("scope cancellation clears recovery timers and closes a late subscription", async () => { + const timerClock = timers(); + let starts = 0; + const stopTimerOwner = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...timerClock, + waitForRateLimit: async () => {}, + startReceiver: async () => { + starts++; + throw new Error("transient"); + }, + }, + ); + await flushUntil(() => timerClock.pending.size === 1); + stopTimerOwner(); + assert.equal(timerClock.pending.size, 0); + assert.equal(starts, 1); + + let finishSubscribe; + let lateCloseCount = 0; + let historyCalls = 0; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: () => + new Promise((resolve) => { + finishSubscribe = () => resolve(() => lateCloseCount++); + }), + fetchEvents: async () => { + historyCalls++; + return []; + }, + publishEvent: async () => {}, + }; + const stopLateOwner = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...timers(), + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async () => {}, + relay, + onReady, + onClosed, + ), + }, + ); + await flushUntil(() => typeof finishSubscribe === "function"); + stopLateOwner(); + finishSubscribe(); + await flushUntil(() => lateCloseCount === 1); + assert.equal(historyCalls, 0, "cancelled receiver must not begin sync"); +}); + +test("scope cancellation during sync fences reconciliation, admission, readiness, and errors", async () => { + let resolveHistory; + let deliver; + let closeCount = 0; + let ready = 0; + const errors = []; + const calls = []; + const relay = { + getSessionEpoch: () => 1, + getConnectionGeneration: () => 1, + subscribeLive: async (_filter, onEvent, _onReady, _timeout, options) => { + deliver = onEvent; + options.onState("eose"); + return () => closeCount++; + }, + fetchEvents: () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + publishEvent: async () => {}, + }; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => ready++, + { + ...timers(), + waitForRateLimit: async () => {}, + startReceiver: (receiverScope, active, onError, onReady, onClosed) => + receiveLifecycle( + receiverScope, + active, + onError, + async (command) => { + calls.push(command); + return null; + }, + relay, + onReady, + onClosed, + ), + }, + ); + await flushUntil(() => resolveHistory !== undefined); + deliver({ id: "queued", kind: 50182, created_at: 1 }); + stop(); + resolveHistory([]); + await flushUntil(() => closeCount === 1); + + assert.deepEqual(calls, []); + assert.deepEqual(errors, []); + assert.equal(ready, 0); +}); + +test("initializer recovery exhausts the bounded budget and preserves its safe terminal outcome", async () => { + const clock = timers(); + let starts = 0; + const errors = []; + ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: async () => { + starts++; + throw new Error("raw private initializer detail"); + }, + }, + ); + + for ( + let attempt = 0; + attempt < RECEIVER_RECOVERY_DELAYS_MS.length; + attempt++ + ) { + await flushUntil(() => clock.pending.size === 1); + assert.equal( + clock.pending.values().next().value.delayMs, + RECEIVER_RECOVERY_DELAYS_MS[attempt], + ); + clock.fireNext(); + } + await flushUntil(() => errors.length === 1); + assert.equal(starts, 4); + assert.equal(clock.pending.size, 0); + assert.equal( + errors[0], + "Desktop lifecycle receiver is unavailable (initialization failed).", + ); + assert.doesNotMatch(errors[0], /private|detail/); +}); + +test("transient CLOSED recovery is bounded across successful receivers; terminal stays manual", async () => { + const clock = timers(); + const closures = []; + let starts = 0; + let closes = 0; + const errors = []; + const stop = ownLifecycleReceiver( + scope, + (error) => errors.push(error), + () => {}, + { + ...clock, + waitForRateLimit: async () => {}, + startReceiver: async (_scope, _active, _onError, onReady, onClosed) => { + starts++; + onReady(); + closures.push(onClosed); + return () => closes++; + }, + }, + ); + await flushUntil(() => closures.length === 1); + + for ( + let attempt = 0; + attempt < RECEIVER_RECOVERY_DELAYS_MS.length; + attempt++ + ) { + closures.at(-1)({ classification: "retryable", retryAfterMs: 0 }); + assert.equal( + clock.pending.values().next().value.delayMs, + RECEIVER_RECOVERY_DELAYS_MS[attempt], + ); + clock.fireNext(); + await flushUntil(() => closures.length === attempt + 2); + } + closures.at(-1)({ classification: "retryable", retryAfterMs: 0 }); + assert.equal(clock.pending.size, 0); + assert.equal(starts, 4); + assert.equal(closes, 4); + assert.equal(errors.length, 1); + assert.match(errors[0], /Retry the receiver/); + stop(); + + const terminalClock = timers(); + let terminalClosed; + const terminalErrors = []; + ownLifecycleReceiver( + scope, + (error) => terminalErrors.push(error), + () => {}, + { + ...terminalClock, + startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => { + terminalClosed = onClosed; + return () => {}; + }, + }, + ); + await flushUntil(() => terminalClosed !== undefined); + terminalClosed({ classification: "terminal", retryAfterMs: 0 }); + assert.equal(terminalClock.pending.size, 0); + assert.equal(terminalErrors.length, 1); +}); + +test("rate-limited recovery honors both the delay and shared gate, and cancellation fences it", async () => { + const clock = timers(); + let releaseGate; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const closures = []; + let starts = 0; + const stop = ownLifecycleReceiver( + scope, + () => {}, + () => {}, + { + ...clock, + waitForRateLimit: () => gate, + startReceiver: async (_scope, _active, _onError, _onReady, onClosed) => { + starts++; + closures.push(onClosed); + return () => {}; + }, + }, + ); + await flushUntil(() => closures.length === 1); + closures[0]({ classification: "rate-limited", retryAfterMs: 8_000 }); + assert.equal(clock.pending.values().next().value.delayMs, 8_000); + clock.fireNext(); + await Promise.resolve(); + assert.equal(starts, 1, "fresh subscription must wait for the active gate"); + stop(); + releaseGate(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(starts, 1, "scope cancellation must fence the late gate result"); +}); diff --git a/desktop/src/features/agents/desktopLifecycleReceiver.ts b/desktop/src/features/agents/desktopLifecycleReceiver.ts new file mode 100644 index 00000000000..62417287e91 --- /dev/null +++ b/desktop/src/features/agents/desktopLifecycleReceiver.ts @@ -0,0 +1,148 @@ +import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientShared"; +import { waitForRateLimit } from "@/shared/api/relayRateLimitGate"; +import type { DesktopScope } from "./desktopList"; +import { receiveLifecycle } from "./desktopLifecycle"; +import { + LifecycleReceiverError, + receiverErrorMessage, +} from "./desktopLifecycleDiagnostics"; + +export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const; + +const CLOSED_MESSAGE = + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests."; + +type StartReceiver = ( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + onReady: () => void, + onClosed: (recovery: LiveSubscriptionClosedRecovery) => void, +) => Promise<() => void>; + +type ReceiverOwnerDependencies = { + startReceiver?: StartReceiver; + waitForRateLimit?: () => Promise; + setTimer?: (callback: () => void, delayMs: number) => number; + clearTimer?: (timer: number) => void; +}; + +/** + * Owns one lifecycle receiver scope. Recovery always creates a fresh live-only + * subscription and lets receiveLifecycle repeat projection sync before it + * admits events. The attempt budget belongs to this owner and is intentionally + * not reset by a successful EOSE/sync followed by another CLOSED. + */ +export function ownLifecycleReceiver( + scope: DesktopScope, + onError: (message: string) => void, + onReady: () => void, + dependencies: ReceiverOwnerDependencies = {}, +) { + const startReceiver: StartReceiver = + dependencies.startReceiver ?? + ((receiverScope, active, reportError, ready, closed) => + receiveLifecycle( + receiverScope, + active, + reportError, + undefined, + undefined, + ready, + closed, + )); + const waitForGate = dependencies.waitForRateLimit ?? waitForRateLimit; + const setTimer = + dependencies.setTimer ?? + ((callback, delayMs) => window.setTimeout(callback, delayMs)); + const clearTimer = + dependencies.clearTimer ?? ((timer) => window.clearTimeout(timer)); + + let stopped = false; + let generation = 0; + let recoveryAttempt = 0; + let timer: number | undefined; + let closeCurrent: (() => void) | undefined; + + const current = (token: number) => !stopped && generation === token; + + const retireCurrent = () => { + const close = closeCurrent; + closeCurrent = undefined; + close?.(); + }; + + const recover = ( + token: number, + terminalMessage: string, + recovery: LiveSubscriptionClosedRecovery, + ) => { + if (!current(token)) return; + generation++; + retireCurrent(); + + if ( + recovery.classification === "terminal" || + recoveryAttempt >= RECEIVER_RECOVERY_DELAYS_MS.length + ) { + onError(terminalMessage); + return; + } + + const delayMs = Math.max( + RECEIVER_RECOVERY_DELAYS_MS[recoveryAttempt], + recovery.retryAfterMs, + ); + recoveryAttempt++; + const waitingGeneration = generation; + timer = setTimer(() => { + timer = undefined; + if (stopped || generation !== waitingGeneration) return; + void waitForGate().then(() => { + if (!stopped && generation === waitingGeneration) start(); + }); + }, delayMs); + }; + + const start = () => { + const token = ++generation; + void startReceiver( + scope, + () => current(token), + (message) => { + if (current(token)) onError(message); + }, + () => { + if (current(token)) onReady(); + }, + (recovery) => recover(token, CLOSED_MESSAGE, recovery), + ) + .then((close) => { + if (current(token)) closeCurrent = close; + else close(); + }) + .catch((error) => { + if (current(token)) + recover(token, receiverErrorMessage(error), { + classification: + error instanceof LifecycleReceiverError + ? error.recoveryClassification + : "retryable", + retryAfterMs: 0, + }); + }); + }; + + start(); + + return () => { + if (stopped) return; + stopped = true; + generation++; + if (timer !== undefined) { + clearTimer(timer); + timer = undefined; + } + retireCurrent(); + }; +} diff --git a/desktop/src/features/agents/desktopList.test.mjs b/desktop/src/features/agents/desktopList.test.mjs new file mode 100644 index 00000000000..828964f432b --- /dev/null +++ b/desktop/src/features/agents/desktopList.test.mjs @@ -0,0 +1,383 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { refreshDesktopList } from "./desktopList.ts"; +import { DesktopListView } from "./ui/KnownDesktops.tsx"; + +const scope = { owner: "owner-a", community: "wss://a.example" }; +const first = { + id: "signed-a", + pubkey: scope.owner, + kind: 30180, + tags: [["d", "desktop-a"]], +}; +const second = { ...first, id: "signed-b", tags: [["d", "desktop-b"]] }; +function fixture() { + const events = new Map([[second.id, second]]); + const calls = []; + let epoch = 0; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_profile") return { event: first }; + if (command === "read_desktop_profiles") + return args.events.map((event) => ({ + id: event.tags[0][1], + name: event.tags[0][1], + updated: 100, + })); + }; + const relay = { + getSessionEpoch: () => epoch, + fetchEvents: async (filter) => { + assert.deepEqual(filter.authors, [scope.owner]); + assert.deepEqual(filter.kinds, [30180]); + assert.ok(filter.limit <= 100); + return [...events.values()].filter( + (event) => !filter["#d"] || filter["#d"].includes(event.tags[0][1]), + ); + }, + publishEvent: async (event) => { + calls.push(event); + events.set(event.id, event); + }, + }; + return { + ipc, + relay, + calls, + events, + switchScope: () => { + epoch++; + }, + }; +} + +test("two same-owner Desktops retained without presence; startup doesn't rewrite", async () => { + const f = fixture(); + const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay); + assert.equal(list.rows.length, 2); + assert.equal(list.local, "desktop-a"); + assert.equal(list.warning, ""); + assert.deepEqual( + await refreshDesktopList(scope, () => true, f.ipc, f.relay), + list, + ); + assert.equal(f.calls.filter((call) => call === first).length, 1); +}); + +test("ACK loss retries the exact object, while disconnected entries remain listed", async () => { + const f = fixture(); + f.relay.publishEvent = async (event) => { + f.calls.push(event); + throw Error("lost ACK"); + }; + for (let retry = 0; retry < 2; retry++) { + const list = await refreshDesktopList(scope, () => true, f.ipc, f.relay); + assert.equal(list.rows.length, 1); + assert.ok(list.warning); + } + assert.deepEqual( + f.calls.filter((call) => typeof call === "object"), + [first, first], + ); + assert.ok(!f.calls.includes("acknowledge_desktop_profile")); +}); + +test("owner/community switch fences prepared bytes and late ACKs", async () => { + for (const boundary of ["prepare_desktop_profile", "publish"]) { + const f = fixture(); + const ipc = async (command, args) => { + const result = await f.ipc(command, args); + if (command === boundary) f.switchScope(); + return result; + }; + if (boundary === "publish") + f.relay.publishEvent = async () => f.switchScope(); + await assert.rejects( + refreshDesktopList(scope, () => true, ipc, f.relay), + /scope changed/, + ); + assert.ok(!f.calls.includes("acknowledge_desktop_profile")); + assert.ok(!f.calls.includes(first)); + } +}); + +test("denied coordinate read is not absence, invalid reader result is not an empty list", async () => { + const f = fixture(); + const fetch = f.relay.fetchEvents; + f.relay.fetchEvents = async (filter) => { + if (filter["#d"]) throw Error("denied"); + return fetch(filter); + }; + assert.ok( + (await refreshDesktopList(scope, () => true, f.ipc, f.relay)).warning, + ); + assert.ok(!f.calls.includes(first)); + await assert.rejects( + refreshDesktopList( + scope, + () => true, + async () => { + throw Error("invalid"); + }, + f.relay, + ), + ); +}); + +test("rendered list distinguishes current, partial, unavailable and empty without online claims", () => { + const list = { + rows: [{ id: "a", name: "Desktop a", updated: 100 }], + local: "a", + warning: "", + partial: true, + }; + const render = (data, error = false) => + renderToStaticMarkup( + React.createElement(DesktopListView, { + list: data, + error, + loading: false, + refresh() {}, + }), + ); + const html = render(list, true); + assert.match(html, /This Desktop/); + assert.match(html, /Profile updated/); + assert.match(html, /Partial list/); + assert.match(html, /unavailable/); + assert.match(html, /Desktop a/); + assert.doesNotMatch(html, /No Desktop profiles found/); + assert.match( + render({ ...list, rows: [], partial: false }), + /No Desktop profiles found/, + ); + assert.match(html, /Last heard: Unknown/); + assert.doesNotMatch(html, /Online|Offline/); +}); + +test("mounted cache clears both scopes, fences late reads and retains rows on failure", async (t) => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider, useCommunities } = await import( + "../communities/useCommunities.tsx" + ); + const { KnownDesktops, DesktopListStartup } = await import( + "./ui/KnownDesktops.tsx" + ); + const { relayClient } = await import("../../shared/api/relayClient.ts"); + const communities = ["a", "b"].map((id) => ({ + id, + name: id, + relayUrl: `wss://${id}.example`, + })); + localStorage.setItem("buzz-communities", JSON.stringify(communities)); + localStorage.setItem("buzz-active-community-id", "a"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["identity"], { pubkey: "owner-a" }); + let controls; + let fail = false; + let hold = false; + let release; + let current; + const originalFetch = relayClient.fetchEvents; + const originalPublish = relayClient.publishEvent; + const originalReconnect = relayClient.subscribeToReconnects; + let reconnect; + let pulses = 0; + let reports = 0; + let publishedReports = 0; + let deferReport = true; + relayClient.subscribeToReconnects = (callback) => { + reconnect = callback; + return () => { + reconnect = undefined; + }; + }; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "prepare_desktop_capabilities") { + reports++; + if (deferReport) throw Error("clock has not advanced"); + return { event: { ...first, kind: 30182 } }; + } + if (command === "read_desktop_capabilities") + return args.events.map(() => ({ + id: "desktop-a", + reported: 100, + runtimes: [], + })); + if (command === "prepare_desktop_observation") + return { event: { ...first, kind: 30181 } }; + if (command === "read_desktop_observations") + return [ + { + id: `${args.owner}-${args.community}`, + heard: Math.floor(Date.now() / 1000), + }, + ]; + if (command === "prepare_desktop_profile") { + current = { + ...first, + id: `${args.owner}-${args.community}`, + tags: [["d", args.owner]], + }; + return { event: current }; + } + assert.equal(command, "read_desktop_profiles"); + return args.events.map((event) => ({ + id: event.id, + name: event.id, + updated: 100, + })); + }, + }; + relayClient.fetchEvents = async (filter) => { + if (fail) throw Error("unavailable"); + if ([30181, 30182].includes(filter.kinds[0])) return []; + if (filter["#d"]) return [current]; + const rows = [current]; + if (hold) { + hold = false; + return new Promise((resolve) => { + release = () => resolve(rows); + }); + } + return rows; + }; + relayClient.publishEvent = async (event) => { + if (event.kind === 30182) { + publishedReports++; + return; + } + assert.equal(event.kind, 30181, "no profile heartbeat rewrite"); + pulses++; + }; + function Screen() { + controls = useCommunities(); + return React.createElement( + React.Fragment, + null, + React.createElement(DesktopListStartup), + React.createElement(KnownDesktops), + ); + } + const root = createRoot(document.getElementById("root")); + const settle = () => + React.act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const text = () => document.body.textContent; + t.mock.timers.enable({ apis: ["setInterval"] }); + try { + await React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Screen), + ), + ), + ), + ); + await settle(); + assert.match(text(), /owner-a-wss:\/\/a.example/); + assert.match(text(), /Last heard: Recent/); + assert.match(text(), /could not synchronize capability facts/); + const beforeReconnect = pulses; + const reportsBeforeReconnect = reports; + await React.act(async () => reconnect()); + await settle(); + assert.ok(pulses > beforeReconnect, "reconnect reports a fresh pulse"); + assert.ok( + reports > reportsBeforeReconnect, + "reconnect retries deferred facts", + ); + const beforeTimer = pulses; + const reportsBeforeTimer = reports; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.ok(pulses > beforeTimer, "bounded periodic publisher runs"); + assert.ok(reports > reportsBeforeTimer, "periodic retry survives deferral"); + assert.equal(publishedReports, 0, "deferred facts are not published"); + deferReport = false; + await React.act(async () => t.mock.timers.tick(60_000)); + await settle(); + assert.equal(publishedReports, 1, "later preparation is published"); + assert.doesNotMatch(text(), /could not synchronize capability facts/); + hold = true; + await React.act(async () => { + void client.refetchQueries({ queryKey: ["desktop-profiles"] }); + }); + assert.equal(typeof release, "function"); + await React.act(async () => { + client.setQueryData(["identity"], { pubkey: "owner-b" }); + }); + await settle(); + assert.doesNotMatch(text(), /owner-a/); + await React.act(async () => release()); + await settle(); + assert.match(text(), /owner-b-wss:\/\/a.example/); + assert.doesNotMatch(text(), /owner-a/); + assert.equal( + client.getQueryData(["desktop-profiles", "owner-a", "wss://a.example"]), + undefined, + ); + fail = true; + await React.act(async () => { + await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); + }); + await settle(); + assert.match(text(), /Last-heard refresh unavailable/); + assert.match(text(), /Last heard: Recent/); + assert.match(text(), /unavailable/); + assert.match(text(), /owner-b-wss:\/\/a.example/); + await React.act(async () => controls.switchCommunity("b")); + await settle(); + assert.doesNotMatch(text(), /owner-b-wss:\/\/a.example/); + fail = false; + await React.act(async () => { + await client.refetchQueries({ queryKey: ["desktop-profiles"] }); + await client.refetchQueries({ queryKey: ["desktop-observations"] }); + }); + await settle(); + assert.match(text(), /owner-b-wss:\/\/b.example/); + } finally { + await React.act(async () => root.unmount()); + client.clear(); + relayClient.fetchEvents = originalFetch; + relayClient.publishEvent = originalPublish; + relayClient.subscribeToReconnects = originalReconnect; + assert.equal( + reconnect, + undefined, + "reconnect producer unsubscribed on unmount", + ); + t.mock.timers.reset(); + globalThis.requestAnimationFrame = originalRaf; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/desktopList.ts b/desktop/src/features/agents/desktopList.ts new file mode 100644 index 00000000000..da3f0cea6d7 --- /dev/null +++ b/desktop/src/features/agents/desktopList.ts @@ -0,0 +1,69 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +export type DesktopRow = { id: string; name: string; updated: number }; +export type DesktopScope = { owner: string; community: string }; +export type DesktopList = { + rows: DesktopRow[]; + local: string; + partial: boolean; + warning: string; +}; +const KIND = 30180; +const LIMIT = 100; + +/** Every continuation belongs to this mounted owner/community, including late ACKs. */ +export async function refreshDesktopList( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + const read = (events: RelayEvent[]) => + wait(ipc("read_desktop_profiles", { ...scope, events })); + const filter = { kinds: [KIND], authors: [scope.owner] }; + check(); + let local = ""; + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_profile", scope), + ); + const [profile] = await read([event]); + local = profile.id; + // A bounded inventory is never evidence that this coordinate is missing. + const head = await wait( + relay.fetchEvents({ ...filter, "#d": [local], limit: 1 }), + ); + await read(head); + if (head.length && head[0].id !== event.id) + throw new Error("Desktop profile differs on relay"); + if (!head.length) + await wait( + relay.publishEvent( + event, + "Desktop publish timed out", + "Desktop publish failed", + ), + ); + } catch { + check(); + warning = + "This Desktop profile could not be synchronized. Retry to publish it."; + } + // Listing is independent of local publication and never uses scalar presence. + const events = await wait(relay.fetchEvents({ ...filter, limit: LIMIT })); + const rows = await read(events); + return { rows, local, partial: events.length === LIMIT, warning }; +} diff --git a/desktop/src/features/agents/desktopObservations.test.mjs b/desktop/src/features/agents/desktopObservations.test.mjs new file mode 100644 index 00000000000..b3166ba25d8 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + refreshDesktopObservations, + desktopFreshness, +} from "./desktopObservations.ts"; + +const scope = { owner: "owner-a", community: "wss://a.example" }; +function fixture(boundary) { + let epoch = 0; + const calls = []; + const event = { id: "pulse", kind: 30181 }; + const rows = [ + { id: "a", heard: 100 }, + { id: "b", heard: 10 }, + ]; + const finish = (name, value) => { + if (name === boundary) epoch++; + return value; + }; + const f = { + calls, + rows, + ipc: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + calls.push(command); + if (command === "prepare_desktop_observation") + return finish(command, { event }); + assert.equal(command, "read_desktop_observations"); + return finish(command, rows); + }, + relay: { + getSessionEpoch: () => epoch, + publishEvent: async (value) => { + assert.equal(value, event); + calls.push("publish"); + finish("publishEvent"); + }, + fetchEvents: async (filter) => { + assert.deepEqual(filter, { + kinds: [30181], + authors: [scope.owner], + limit: 100, + }); + calls.push("fetch"); + return finish("fetchEvents", [event]); + }, + }, + }; + f.refresh = (active = () => true) => + refreshDesktopObservations(scope, active, f.ipc, f.relay); + return f; +} + +test("every async completion is fenced, including decrypt and late ACK", async () => { + for (const boundary of [ + "prepare_desktop_observation", + "publishEvent", + "fetchEvents", + "read_desktop_observations", + ]) { + const f = fixture(boundary); + await assert.rejects(f.refresh(), /scope changed/); + if (boundary === "prepare_desktop_observation") + assert.ok(!f.calls.includes("publish")); + } + const f = fixture(); + await assert.rejects(f.refresh(() => false)); + assert.deepEqual(f.calls, []); +}); + +test("bounded/invalid reads are not silently authoritative, clocks and staleness stay advisory", async () => { + const f = fixture(); + const good = await f.refresh(); + assert.deepEqual(good.rows, f.rows); + assert.deepEqual(f.calls, [ + "prepare_desktop_observation", + "publish", + "fetch", + "read_desktop_observations", + ]); + f.relay.publishEvent = async () => { + throw Error("offline"); + }; + assert.ok((await f.refresh()).warning); + f.relay.fetchEvents = async () => Array(100).fill({}); + assert.equal((await f.refresh()).partial, true); + const read = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + throw Error("invalid signature"); + return read(command, args); + }; + await assert.rejects(f.refresh(), /invalid signature/); + for (const [heard, now, expected] of [ + [undefined, 100, "Unknown"], + [100, 280, "Recent"], + [100, 281, "Stale"], + [101, 100, "Unknown (Desktop clock ahead)"], + ]) + assert.ok(desktopFreshness(heard, now).startsWith(expected)); +}); + +test("history/live replacement races select newest signed time and lower ID, not arrival", async () => { + const f = fixture(); + const events = [ + { id: "old", created_at: 10 }, + { id: "b", created_at: 20 }, + { id: "a", created_at: 20 }, + ]; + const ipc = f.ipc; + f.ipc = async (command, args) => { + if (command === "read_desktop_observations") + assert.deepEqual( + args.events.map((e) => e.id), + ["a", "b", "old"], + ); + return ipc(command, args); + }; + for (const batch of [events, [...events].reverse()]) { + f.relay.fetchEvents = async () => batch; + await f.refresh(); + } +}); diff --git a/desktop/src/features/agents/desktopObservations.ts b/desktop/src/features/agents/desktopObservations.ts new file mode 100644 index 00000000000..d548fa42433 --- /dev/null +++ b/desktop/src/features/agents/desktopObservations.ts @@ -0,0 +1,89 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQuery } from "@tanstack/react-query"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export type DesktopObservation = { id: string; heard: number }; +export const DESKTOP_PULSE_MS = 60_000; + +/** A failed pulse does not hide other Desktops; failed reads retain cached observations. */ +export async function refreshDesktopObservations( + scope: DesktopScope, + active: () => boolean, + ipc = invoke, + relay = relayClient, +) { + const epoch = relay.getSessionEpoch(); + const check = () => { + if (!active() || epoch !== relay.getSessionEpoch()) + throw new Error("Desktop observation scope changed"); + }; + const wait = async (work: Promise) => { + const result = await work; + check(); + return result; + }; + check(); + let warning = ""; + try { + const { event } = await wait( + ipc<{ event: RelayEvent }>("prepare_desktop_observation", scope), + ); + await wait( + relay.publishEvent( + event, + "Desktop pulse timed out", + "Desktop pulse failed", + check, + ), + ); + } catch { + check(); + // The next bounded interval retries with a new observation, not an old heartbeat. + warning = "This Desktop could not report its last-heard time. Will retry."; + } + const events = await wait( + relay.fetchEvents({ kinds: [30181], authors: [scope.owner], limit: 100 }), + ); + const rows = await wait( + ipc("read_desktop_observations", { + ...scope, + // History is chronological and may include a live replacement before EOSE. + // First matching host wins in the view: newest signed time, then lower ID. + events: [...events].sort( + (a, b) => + b.created_at - a.created_at || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ), + }), + ); + return { rows, warning, partial: events.length === 100 }; +} + +export function useDesktopObservations(scope: DesktopScope | null) { + return useQuery({ + queryKey: ["desktop-observations", scope?.owner, scope?.community], + enabled: !!scope, + queryFn: ({ signal }) => { + if (!scope) throw new Error("Desktop scope unavailable"); + return refreshDesktopObservations(scope, () => !signal.aborted); + }, + gcTime: 0, + staleTime: DESKTOP_PULSE_MS / 2, + retry: false, + refetchOnWindowFocus: false, + }); +} + +/** Signed sender time is advisory, including clock skew; it never establishes agent death. */ +export function desktopFreshness(heard: number | undefined, now: number) { + if (heard === undefined) return "Unknown"; + const state = + heard > now + ? "Unknown (Desktop clock ahead)" + : now - heard <= 180 + ? "Recent" + : "Stale"; + return `${state} · ${new Date(heard * 1000).toLocaleString()}`; +} diff --git a/desktop/src/features/agents/desktopStop.test.mjs b/desktop/src/features/agents/desktopStop.test.mjs new file mode 100644 index 00000000000..015a39480e2 --- /dev/null +++ b/desktop/src/features/agents/desktopStop.test.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + prepareStop, + readStopOutcome, + receiveStops, + sendStop, +} from "./desktopStop.ts"; + +const scope = { owner: "owner", community: "wss://one.example" }; +const request = { + id: "request", + kind: 50180, + pubkey: scope.owner, + tags: [["d", "desktop"]], +}; +const result = { + id: "result", + kind: 50181, + pubkey: scope.owner, + tags: [["e", request.id]], +}; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function fixture() { + let epoch = 0; + let live; + let closed = false; + let effect = 0; + let outcome; + let failResult = false; + const saved = new Map(); + const stored = new Map(); + const publishes = []; + const errors = []; + const ipc = async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "prepare_desktop_stop") return request; + if (command === "receive_desktop_stop") { + if (!saved.has(args.event.id)) { + effect++; + saved.set(args.event.id, result); + } + return saved.get(args.event.id); + } + if (command === "read_desktop_stop_results") { + assert.equal(args.request, request); + return args.events.includes(result) ? "stopped" : "unknown"; + } + throw Error(command); + }; + const relay = { + getSessionEpoch: () => epoch, + publishEvent: async (event, _timeout, _failure, check) => { + check(); + publishes.push(event); + if (event.kind === 50180) { + stored.set(event.id, event); + // Real relay contract: same immutable Stop is explicitly redelivered. + live?.(event); + } else { + if (failResult) throw Error("lost result publish"); + outcome = event; + } + }, + fetchEvents: async (filter) => { + assert.deepEqual(filter, { + kinds: [50181], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + return outcome ? [outcome] : []; + }, + subscribeLive: async (filter, onEvent, ready) => { + assert.deepEqual(filter, { + kinds: [50180], + authors: [scope.owner], + limit: 0, + }); + live = onEvent; + ready("eose"); + return () => { + live = undefined; + closed = true; + }; + }, + }; + return { + ipc, + relay, + publishes, + errors, + stored, + saved, + effect: () => effect, + closed: () => closed, + deliver: () => live?.(request), + switchScope: () => { + epoch++; + }, + failResult: (value) => { + failResult = value; + }, + }; +} + +test("lost delivery/result recovers only on explicit exact-byte retry, not history replay", async () => { + const f = fixture(); + const prepared = await prepareStop( + scope, + "desktop", + "agent", + () => true, + f.ipc, + f.relay, + ); + await sendStop(scope, prepared, () => true, f.relay); // target absent + assert.equal(f.effect(), 0); + const close = await receiveStops( + scope, + () => true, + (e) => f.errors.push(e), + f.ipc, + f.relay, + ); + assert.equal( + f.effect(), + 0, + "opening receiver cannot dispatch stored requests", + ); + assert.equal( + await readStopOutcome(scope, request, () => true, f.ipc, f.relay), + "unknown", + ); + assert.equal(f.effect(), 0, "status is read-only"); + f.failResult(true); + await sendStop(scope, prepared, () => true, f.relay); + await tick(); + assert.equal(f.effect(), 1); + assert.equal(f.errors.length, 1); + f.failResult(false); + await sendStop(scope, prepared, () => true, f.relay); + await tick(); + assert.equal( + f.effect(), + 1, + "consumed request returns saved outcome without effect", + ); + assert.equal( + await readStopOutcome(scope, request, () => true, f.ipc, f.relay), + "stopped", + ); + assert.ok( + f.publishes.filter((e) => e.kind === 50180).every((e) => e === request), + ); + assert.ok( + f.publishes.filter((e) => e.kind === 50181).every((e) => e === result), + ); + close(); + assert.equal(f.closed(), true); +}); + +test("duplicate delivery during native Stop/result publication is coalesced", async () => { + const f = fixture(); + let release; + const wait = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const ipc = async (...args) => { + calls++; + await wait; + return f.ipc(...args); + }; + const close = await receiveStops( + scope, + () => true, + () => {}, + ipc, + f.relay, + ); + f.deliver(); + f.deliver(); + assert.equal(calls, 1); + release(); + await tick(); + assert.equal(f.effect(), 1); + close(); +}); + +test("scope change after native effect prevents result publication", async () => { + const f = fixture(); + const ipc = async (...args) => { + const value = await f.ipc(...args); + f.switchScope(); + return value; + }; + const close = await receiveStops( + scope, + () => true, + () => {}, + ipc, + f.relay, + ); + f.deliver(); + await tick(); + assert.equal(f.effect(), 1, "dispatched Stop may finish"); + assert.equal( + f.publishes.length, + 0, + "late result cannot cross the scope boundary", + ); + close(); +}); + +test("publish rate-limit/reconnect wait rechecks mounted owner scope before send", async () => { + const f = fixture(); + let active = true; + f.relay.publishEvent = async (_event, _timeout, _failure, check) => { + active = false; + check(); + }; + await assert.rejects( + sendStop(scope, request, () => active, f.relay), + /scope changed/, + ); + const ipc = async (...args) => { + const value = await f.ipc(...args); + f.switchScope(); + return value; + }; + await assert.rejects( + prepareStop(scope, "desktop", "agent", () => true, ipc, f.relay), + /scope changed/, + ); +}); diff --git a/desktop/src/features/agents/desktopStop.ts b/desktop/src/features/agents/desktopStop.ts new file mode 100644 index 00000000000..3385813c691 --- /dev/null +++ b/desktop/src/features/agents/desktopStop.ts @@ -0,0 +1,146 @@ +import { invoke } from "@tauri-apps/api/core"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopScope } from "./desktopList"; + +export const DESKTOP_STOP = 50180; +export const DESKTOP_STOP_RESULT = 50181; +export type StopOutcome = "stopped" | "failed" | "unknown"; + +function guard( + scope: DesktopScope, + active: () => boolean, + relay: typeof relayClient, +) { + const epoch = relay.getSessionEpoch(); + return () => { + if (!active() || relay.getSessionEpoch() !== epoch) + throw new Error(`Desktop Stop scope changed (${scope.community})`); + }; +} + +/** A mounted operation retains the exact signed request for explicit retry. */ +export async function prepareStop( + scope: DesktopScope, + desktop: string, + agent: string, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + const request = await ipc("prepare_desktop_stop", { + ...scope, + desktop, + agent, + }); + check(); + return request; +} + +/** ACK is delivery only; a missing authenticated correlated result is Unknown. */ +export async function sendStop( + scope: DesktopScope, + request: RelayEvent, + active: () => boolean, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + await relay.publishEvent( + request, + "Stop delivery unconfirmed", + "Stop delivery failed", + check, + ); + check(); +} + +export async function readStopOutcome( + scope: DesktopScope, + request: RelayEvent, + active: () => boolean, + ipc = invoke, + relay = relayClient, +): Promise { + const check = guard(scope, active, relay); + check(); + const events = await relay.fetchEvents({ + kinds: [DESKTOP_STOP_RESULT], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + check(); + const outcome = await ipc("read_desktop_stop_results", { + ...scope, + request, + events, + }); + check(); + return outcome; +} + +/** Live only: never fetch or replay historical commands when Desktop reopens. */ +export async function receiveStops( + scope: DesktopScope, + active: () => boolean, + onError: (message: string) => void, + ipc = invoke, + relay = relayClient, +) { + const check = guard(scope, active, relay); + const pending = new Set(); + check(); + const unsubscribe = await relay.subscribeLive( + { kinds: [DESKTOP_STOP], authors: [scope.owner], limit: 0 }, + (event) => { + if (!active()) return; + if (pending.has(event.id)) return; + if (pending.size >= 16) { + onError( + "Remote Stop receiver is busy. Unconfirmed requests can be retried.", + ); + return; + } + pending.add(event.id); + void (async () => { + check(); + const result = await ipc("receive_desktop_stop", { + ...scope, + event, + }); + check(); + if (result) + await relay.publishEvent( + result, + "Stop result delivery unconfirmed", + "Stop result delivery failed", + check, + ); + check(); + })() + .catch(() => { + if (active()) + onError( + "A remote Stop result could not be confirmed. Retry the same Stop to request its saved outcome.", + ); + }) + .finally(() => { + pending.delete(event.id); + }); + }, + (readiness) => { + if (active() && readiness !== "eose") + onError("Remote Stop receiver is unavailable."); + }, + ); + try { + check(); + } catch (error) { + unsubscribe(); + throw error; + } + return unsubscribe; +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ec1ccd262e8..8f2fe3ca1c0 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -595,6 +595,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl?: string; expectedSignerPubkey?: string; replayFloorUnix?: number; + explicitStart?: boolean; }, ) => typeof input === "string" @@ -603,6 +604,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, replayFloorUnix: input.replayFloorUnix, + explicitStart: input.explicitStart, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2e..e2efef08c33 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -56,7 +56,10 @@ test("relay-mesh agents delegate start to the backend preflight", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, meshAgent.pubkey); + assert.deepEqual(calledWith, { + pubkey: meshAgent.pubkey, + explicitStart: true, + }); // Backend preflight failures (e.g. no live serve target) propagate as-is. await assert.rejects( @@ -78,7 +81,10 @@ test("ordinary local agents still start normally", async () => { calledWith = pubkey; }, }); - assert.equal(calledWith, "deadbeef".repeat(8)); + assert.deepEqual(calledWith, { + pubkey: "deadbeef".repeat(8), + explicitStart: true, + }); }); // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index aaf10075e0d..d0fea69a062 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -8,7 +8,10 @@ type DeleteManagedAgentInput = { forceRemoteDelete?: boolean; }; -type StartManagedAgent = (pubkey: string) => Promise; +export type ManagedAgentStartInput = + | string + | { pubkey: string; explicitStart: true }; +type StartManagedAgent = (input: ManagedAgentStartInput) => Promise; type StopManagedAgent = (pubkey: string) => Promise; type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise; @@ -82,7 +85,7 @@ export async function startManagedAgentWithRules({ // Relay-mesh agents are no longer blocked here: the backend start preflight // (ensure_relay_mesh_for_record) re-resolves a live serve target and dials // it, failing with an actionable error when no peer serves the model. - await startManagedAgent(agent.pubkey); + await startManagedAgent({ pubkey: agent.pubkey, explicitStart: true }); } export async function respawnManagedAgentWithRules({ diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs index 21327c30d92..c19fb1f3792 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs @@ -21,7 +21,7 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" const relays = canonicalCommunityRelays( [ { relayUrl: "ws://localhost:3000" }, - // Same relay, different spelling — folds onto the first entry. + // A distinct loopback authority is a distinct community. { relayUrl: "ws://127.0.0.1:3000" }, { relayUrl: "wss://relay.example" }, // Unparsable entries are dropped rather than reconciled. @@ -32,7 +32,8 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" assert.deepEqual( [...relays.entries()], [ - ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["ws://localhost:3000", "ws://localhost:3000"], + ["ws://127.0.0.1:3000", "ws://127.0.0.1:3000"], ["wss://relay.example", "wss://relay.example"], ], ); @@ -40,7 +41,8 @@ test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling" test("pendingReconcileRelays skips reconciled and in-flight relays", () => { const canonicalToRequested = new Map([ - ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["ws://localhost:3000", "ws://localhost:3000"], + ["ws://127.0.0.1:3000", "ws://127.0.0.1:3000"], ["wss://a.example", "wss://a.example"], ["wss://b.example", "wss://b.example"], ]); @@ -49,7 +51,7 @@ test("pendingReconcileRelays skips reconciled and in-flight relays", () => { new Set(["wss://a.example"]), new Set(["ws://127.0.0.1:3000"]), ); - assert.deepEqual(pending, ["wss://b.example"]); + assert.deepEqual(pending, ["ws://localhost:3000", "wss://b.example"]); }); test("classifyReconcileResult marks the whole batch failed when the call throws", () => { @@ -64,7 +66,7 @@ test("classifyReconcileResult marks the whole batch failed when the call throws" }); test("classifyReconcileResult splits by Failed rows, matching on requested URL", () => { - const attempted = ["ws://127.0.0.1:3000", "wss://b.example"]; + const attempted = ["ws://localhost:3000", "wss://b.example"]; const rows = [ // Started cleanly on the loopback relay — reconciled. { @@ -92,7 +94,7 @@ test("classifyReconcileResult splits by Failed rows, matching on requested URL", assert.deepEqual( classifyReconcileResult(attempted, rows, canonicalRelayUrl), { - succeeded: ["ws://127.0.0.1:3000"], + succeeded: ["ws://localhost:3000"], failed: ["wss://b.example"], }, ); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs index 3e961b58544..2356f720bc4 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs @@ -2,109 +2,88 @@ import assert from "node:assert/strict"; import test from "node:test"; import { restartManagedAgentPair } from "./managedAgentRuntimeHooks.ts"; - -// --------------------------------------------------------------------------- -// restartManagedAgentPair: discriminating regression tests for the pair -// restart lifecycle boundary (stop → relay-scoped clear → start). -// -// These tests exercise the exact function called by useManagedAgentRuntimeAction's -// mutationFn restart branch, so reverting to the old combined Rust command -// (which cleared only in onSuccess, after the new process was already running) -// would make tests (a) and (c) fail. -// --------------------------------------------------------------------------- +import { + captureActiveTurnsForAgentClear, + getActiveTurnsForAgent, + resetActiveAgentTurnsStore, + syncAgentTurnsFromEvents, +} from "./activeAgentTurnsStore.ts"; const PUBKEY = "deadbeef".repeat(8); const RELAY = "wss://relay.example"; +const status = (lifecycle = "running") => ({ + pubkey: PUBKEY, + relayUrl: RELAY, + localSetup: true, + lifecycle, +}); -/** Returns a resolved-status stub sufficient for the return-type assertion. */ -function makeStatus() { - return { - pubkey: PUBKEY, - relayUrl: RELAY, - localSetup: true, - lifecycle: "running", - }; -} - -test("test_pair_restart_stop_success_start_failure_clear_still_ran", async () => { - // Stop succeeds, start throws. The clear must have fired — badge is gone - // regardless of the start failure. On the old combined-command approach, - // a rejected command meant onSuccess never ran and the badge survived. - let clearFired = false; - +test("restart preflight refusal leaves old turns alone", async () => { + const calls = []; await assert.rejects( restartManagedAgentPair( PUBKEY, RELAY, - async () => makeStatus(), // stop succeeds - (_pubkey, _relayUrl) => { - clearFired = true; + async (pubkey, relay) => { + assert.equal(pubkey, PUBKEY); + assert.equal(relay, RELAY); + calls.push("native-restart"); + throw new Error("selected provider unavailable"); }, - async () => { - throw new Error("start failed"); + () => { + calls.push("capture-old-turns"); + return () => calls.push("clear"); }, ), - /start failed/, - ); - - assert.ok( - clearFired, - "clear must fire at stop-success boundary even when start subsequently fails", + /selected provider unavailable/, ); + assert.deepEqual(calls, ["capture-old-turns", "native-restart"]); }); -test("test_pair_restart_stop_failure_neither_clear_nor_start_called", async () => { - // Stop throws. Neither clear nor start should run — clearing on a failed - // stop would remove a badge that is still legitimately active. - let clearFired = false; - let startCalled = false; - +test("successful Stop with failed replacement retires old turns and reports failure", async () => { + let cleared = false; await assert.rejects( restartManagedAgentPair( PUBKEY, RELAY, - async () => { - throw new Error("stop failed"); - }, - (_pubkey, _relayUrl) => { - clearFired = true; - }, - async () => { - startCalled = true; - return makeStatus(); + async () => ({ ...status("failed"), error: "replacement failed" }), + () => () => { + cleared = true; }, ), - /stop failed/, + /replacement failed/, ); - - assert.ok(!clearFired, "clear must NOT fire when stop itself fails"); - assert.ok(!startCalled, "start must NOT be called when stop fails"); + assert.equal(cleared, true); }); -test("test_pair_restart_strict_stop_clear_start_ordering", async () => { - // Verify the operations fire in the guaranteed order: stop → clear → start. - // A clear that fires after start begins can tombstone genuine new turns. - const events = []; - - await restartManagedAgentPair( +test("native restart clears only captured turns, not replacement turns", async () => { + resetActiveAgentTurnsStore(); + const event = (turnId, channelId, seq) => ({ + seq, + timestamp: new Date(Date.now() + seq).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId, + sessionId: "session", + turnId, + payload: null, + }); + syncAgentTurnsFromEvents(PUBKEY, [event("old", "old-channel", 1)]); + const result = await restartManagedAgentPair( PUBKEY, RELAY, async () => { - events.push("stop"); - return makeStatus(); - }, - (_pubkey, _relayUrl) => { - events.push("clear"); - }, - async () => { - events.push("start"); - return makeStatus(); + syncAgentTurnsFromEvents(PUBKEY, [ + event("replacement", "new-channel", 2), + ]); + return status(); }, + (pubkey) => captureActiveTurnsForAgentClear(pubkey), ); - + assert.equal(result.lifecycle, "running"); assert.deepEqual( - events, - ["stop", "clear", "start"], - "operations must fire in stop → clear → start order", + getActiveTurnsForAgent(PUBKEY).map((turn) => turn.channelId), + ["new-channel"], ); + resetActiveAgentTurnsStore(); }); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 96a3abc78d4..65826e015d9 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -5,7 +5,10 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { clearActiveTurnsForAgent } from "@/features/agents/activeAgentTurnsStore"; +import { + clearActiveTurnsForAgent, + captureActiveTurnsForAgentClear, +} from "@/features/agents/activeAgentTurnsStore"; import { loadActiveCommunityId, loadCommunities, @@ -13,6 +16,7 @@ import { import { listManagedAgentRuntimes, reconcileManagedAgentRuntimes, + restartManagedAgentRuntime, startManagedAgentRuntime, stopManagedAgentRuntime, } from "@/shared/api/tauriManagedAgents"; @@ -147,35 +151,44 @@ export function clearActiveTurnsForAgentOnStop( clearActiveTurnsForAgent(pubkey); } -/** - * Execute a pair restart as stop → relay-scoped badge clear → start. - * - * Extracted from `useManagedAgentRuntimeAction`'s `mutationFn` so the - * three-step lifecycle boundary can be tested directly without a hook-render - * harness. All three operations are injected, keeping this function free of - * React and Tauri imports. - * - * Guarantees: - * - Clear fires only when stop succeeds. - * - A failed start occurs after the clear — the badge is already gone. - * - No clear can fire after start begins, so genuinely-new turns are safe. - */ +/** A native restart owns capture → preflight → locked Stop/spawn. Never split + * it into frontend Stop/Start, which destroys the old child before validation. */ export async function restartManagedAgentPair( pubkey: string, relayUrl: string, - stop: ( - pubkey: string, - relayUrl: string, - ) => Promise, - clear: (pubkey: string, relayUrl: string) => void, - start: ( + restart: ( pubkey: string, relayUrl: string, ) => Promise, + captureClear: (pubkey: string, relayUrl: string) => () => void, ): Promise { - await stop(pubkey, relayUrl); - clear(pubkey, relayUrl); - return start(pubkey, relayUrl); + const clearOldTurns = captureClear(pubkey, relayUrl); + const status = await restart(pubkey, relayUrl); + // Native returns Failed only after successful Stop + failed replacement. + // A preflight/admission/Stop failure throws and must not clear live old turns. + clearOldTurns(); + if (status.lifecycle === "failed") { + throw new Error(status.error ?? "Agent stopped but replacement failed"); + } + return status; +} + +function capturePairTurnsClear(pubkey: string, relayUrl: string): () => void { + const activeId = loadActiveCommunityId(); + const community = loadCommunities().find((c) => c.id === activeId); + const relay = canonicalRelayUrl(relayUrl); + if ( + !activeId || + !community || + !relay || + canonicalRelayUrl(community.relayUrl) !== relay + ) { + return () => {}; + } + const clear = captureActiveTurnsForAgentClear(pubkey); + return () => { + if (loadActiveCommunityId() === activeId) clear(); + }; } export function useManagedAgentRuntimeAction() { @@ -185,26 +198,27 @@ export function useManagedAgentRuntimeAction() { action, pubkey, relayUrl, + explicitStart = false, }: { action: "start" | "stop" | "restart"; pubkey: string; relayUrl: string; + explicitStart?: boolean; }) => { if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); if (action === "restart") { return restartManagedAgentPair( pubkey, relayUrl, - stopManagedAgentRuntime, - clearActiveTurnsForAgentOnStop, - startManagedAgentRuntime, + restartManagedAgentRuntime, + capturePairTurnsClear, ); } - return startManagedAgentRuntime(pubkey, relayUrl); + return startManagedAgentRuntime(pubkey, relayUrl, explicitStart); }, onSuccess: (runtime, { action }) => { // For stop-only: clear stale working badges immediately. The restart - // path already clears at the stop-success boundary inside mutationFn. + // path retires only its captured old turns inside mutationFn. if (action === "stop") { clearActiveTurnsForAgentOnStop(runtime.pubkey, runtime.relayUrl); } diff --git a/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs index 4ec07c91706..e15aa93a07d 100644 --- a/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs @@ -47,3 +47,16 @@ test("startup reconcile preserves unrelated runtime rows", () => { [discovered, existing], ); }); + +test("startup reconcile keeps same-agent loopback authority rows distinct", () => { + const localhost = runtime({ relayUrl: "ws://localhost:3000" }); + const numeric = runtime({ + relayUrl: "ws://127.0.0.1:3000", + lifecycle: "ready", + }); + + assert.deepEqual( + mergeManagedAgentRuntimeStatuses([localhost], [localhost], [numeric]), + [numeric, localhost], + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index edd368eccd6..0b14bf9b4ab 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { agentCommunityAvailability, agentCommunityStatusDetail, + canonicalBestieRelayUrl, canonicalRelayUrl, findManagedAgentRuntime, managedAgentRuntimeKey, @@ -78,9 +79,8 @@ test("selects one relay without collapsing same-pubkey pairs", () => { }); test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { - // Loopback folding + default-port and trailing-slash stripping — the - // standard dev setup that previously broke pair matching. - assert.equal(canonicalRelayUrl("ws://localhost:3000"), "ws://127.0.0.1:3000"); + assert.equal(canonicalRelayUrl("ws://localhost:3000"), "ws://localhost:3000"); + assert.equal(canonicalRelayUrl("ws://127.0.0.1:3000"), "ws://127.0.0.1:3000"); assert.equal( canonicalRelayUrl("WSS://Relay.Example:443/"), "wss://relay.example", @@ -91,23 +91,57 @@ test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { ); assert.equal( canonicalRelayUrl("wss://relay.example/path/"), - "wss://relay.example/path", + "wss://relay.example/path/", + ); + assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://[::1]:3000"); + assert.equal( + canonicalRelayUrl("wss://relay.example/community/?mode=one"), + "wss://relay.example/community/?mode=one", + ); + assert.equal( + canonicalRelayUrl("wss://relay.example/?"), + "wss://relay.example?", + ); + assert.notEqual( + canonicalRelayUrl("wss://relay.example/?"), + canonicalRelayUrl("wss://relay.example"), ); - assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://127.0.0.1:3000"); assert.equal(canonicalRelayUrl("https://relay.example"), null); + assert.equal(canonicalRelayUrl("wss://user@relay.example"), null); + assert.equal(canonicalRelayUrl("wss://relay.example/#"), null); + assert.equal(canonicalRelayUrl("wss://relay.example/#fragment"), null); assert.equal(canonicalRelayUrl("not a url"), null); }); -test("matches a stored community URL against canonical backend rows", () => { +test("Bestie retains its Rust legacy equivalence without widening runtime identity", () => { + assert.equal( + canonicalBestieRelayUrl("ws://localhost:3000"), + "ws://127.0.0.1:3000", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/path/"), + "wss://relay.example/path", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/?mode=one"), + "wss://relay.example/?mode=one", + ); + assert.equal( + canonicalBestieRelayUrl("wss://relay.example/?"), + "wss://relay.example/?", + ); +}); + +test("runtime lookup never crosses loopback community authorities", () => { const runtimes = [ runtime({ relayUrl: "ws://127.0.0.1:3000", lifecycle: "ready" }), ]; assert.equal( - findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3000")?.lifecycle, - "ready", + findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3000"), + undefined, ); assert.equal( - findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3001"), - undefined, + findManagedAgentRuntime(runtimes, "aa", "ws://127.0.0.1:3000"), + runtimes[0], ); }); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index c3a952f7d5d..1aa9a395eeb 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -63,30 +63,54 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< }; /** - * Canonicalize a relay URL the way the backend keys runtime pairs, so a - * stored community URL (e.g. `ws://localhost:3000`) matches backend rows - * (`ws://127.0.0.1:3000`). Mirrors buzz-core's `normalize_relay_url` - * (`crates/buzz-core/src/relay.rs`): lowercase host, loopback hosts folded - * to 127.0.0.1, default ports and root-path trailing slash stripped. - * Returns null when the URL cannot be parsed as ws/wss. + * Canonicalize a relay URL the way the backend keys runtime pairs. Host + * spellings remain distinct because the relay authority is the community: + * `localhost`, `127.*`, and `::1` must never select one another's process. + * DNS case/default ports and a root slash are syntax-only; non-root paths, + * queries, and meaningful trailing slashes are preserved. */ export function canonicalRelayUrl(raw: string): string | null { + const input = raw.trim(); let url: URL; try { - url = new URL(raw.trim()); + url = new URL(input); } catch { return null; } if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + if (url.username !== "" || url.password !== "" || input.includes("#")) + return null; + const host = url.hostname.toLowerCase(); + const defaultPort = url.protocol === "ws:" ? "80" : "443"; + const port = url.port && url.port !== defaultPort ? `:${url.port}` : ""; + const path = url.pathname === "/" ? "" : url.pathname; + const query = url.search || (url.href.endsWith("?") ? "?" : ""); + return `${url.protocol}//${host}${port}${path}${query}`; +} + +/** + * Bestie's Rust scope check intentionally retains buzz-core's legacy + * loopback-folding equivalence. Keep its React Query cache key aligned without + * reusing that broader equivalence for managed-runtime identity. + */ +export function canonicalBestieRelayUrl(raw: string): string | null { + const input = raw.trim(); + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + if (url.username !== "" || url.password !== "" || input.includes("#")) + return null; let host = url.hostname.toLowerCase(); if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) { host = "127.0.0.1"; } - const defaultPort = url.protocol === "ws:" ? "80" : "443"; - const port = url.port && url.port !== defaultPort ? `:${url.port}` : ""; - const path = url.pathname === "/" ? "" : url.pathname; - // The backend trims trailing slashes from the final rendered URL. - return `${url.protocol}//${host}${port}${path}${url.search}`.replace( + const port = url.port ? `:${url.port}` : ""; + const query = url.search || (url.href.endsWith("?") ? "?" : ""); + return `${url.protocol}//${host}${port}${url.pathname}${query}`.replace( /\/+$/, "", ); @@ -98,10 +122,8 @@ export function findManagedAgentRuntime( relayUrl: string, ): ManagedAgentRuntimeStatus | undefined { const normalizedPubkey = pubkey.toLowerCase(); - // Backend rows carry the canonical pair URL; the caller passes the - // community's stored URL, which may differ in spelling (localhost vs - // 127.0.0.1, default port, trailing slash). Compare canonically, keeping - // the exact-string checks as a fallback for unparsable stored URLs. + // Backend rows carry the canonical pair URL; compare syntax-equivalent + // spellings while preserving distinct host authorities. const canonical = canonicalRelayUrl(relayUrl); return runtimes.find( (runtime) => diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a31fec44c49..5c879e70913 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,3 +1,5 @@ +import { RuntimeConfigurations } from "./RuntimeConfigurations"; +import { KnownDesktops } from "./KnownDesktops"; import * as React from "react"; import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { @@ -217,6 +219,8 @@ export function AgentsView() { description="Set up and manage your agents." title="Agents" /> + +
+ Capability details + {!report ? ( +

No capability report received.

+ ) : ( + <> +

+ Facts reported {new Date(report.reported * 1000).toLocaleString()} + {report.reported > now && + " (Desktop clock ahead; report time uncertain)"} + . Unchanged facts keep their original report time. +

+
    + {report.runtimes.map((runtime) => ( +
  • + {runtime.id}: {runtime.availability.replaceAll("_", " ")} · + external CLI{" "} + {runtime.requires_external_cli ? "required" : "not required"} · + parallelism cap {runtime.max_parallelism ?? "not configured"}. +
  • + ))} +
+ {!report.runtimes.length && ( +

No built-in runtime facts reported.

+ )} + + )} +

+ Cached installation facts only, not agent readiness or access to an + agent’s signing key. Stable agent keys must be provisioned separately by + you. For local setup and Check again, use Settings → Agents. +

+ + ); +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs new file mode 100644 index 00000000000..786130fa354 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.test.mjs @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl.tsx"; +import { toast } from "sonner"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +function probeResult(request) { + if (request.action !== "catalog" && request.action !== "preflight") + return null; + const id = request.cursor ? "other" : "config"; + return { + outcome: "ready", + observation: { + valid_until: Math.floor(Date.now() / 1000) + 30, + catalog: + request.action === "catalog" + ? { + entry: { + configuration: { id, revision: "revision-1" }, + name: id === "config" ? "Everyday" : "Alternative", + host: request.desktop, + runtime: "fixture-harness", + model: id === "config" ? "Model A" : "Model B", + provider: null, + eligible: true, + }, + next: request.cursor ? null : "config", + } + : null, + }, + }; +} + +test("mounted picker expires without refresh, reports actual ref, and preserves exact retry", async (t) => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const scope = { owner: "owner", community: "wss://one.example" }; + const originals = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = [], + sent = []; + let stop = "failed"; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") return ["source", "selection"]; + if ( + command === "prepare_desktop_lifecycle" || + command === "prepare_desktop_stop" + ) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return ( + probeResult(args.request) ?? { + outcome: + args.request.action === "status" ? "running" : "ineligible", + observation: { + running_configuration: { + id: "actual-config", + revision: "actual-revision", + }, + }, + } + ); + if (command === "read_desktop_stop_results") return stop; + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (event, _timeout, _failure, check) => { + check(); + sent.push(event); + }; + const root = createRoot(document.getElementById("root")); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + try { + await React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(DesktopLifecycleControl, { + scope, + desktops: [ + { id: "source", name: "Source" }, + { id: "destination", name: "Destination" }, + ], + }), + ), + ), + ); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + await select("Agent to place", "agent"); + await select("Runtime configuration", "destination:config:revision-1"); + assert.match(document.body.textContent, /Model A/); + assert.match(document.body.textContent, /Model B/); + assert.match( + document.querySelector('[aria-label="Actual running configuration"]') + .textContent, + /actual-config, revision actual-revision/, + ); + assert.doesNotMatch( + document.querySelector('[aria-label="Actual running configuration"]') + .textContent, + /revision-1/, + ); + await click("Start on destination"); + assert.match( + document.body.textContent, + /exact configuration is missing, changed, or unavailable/, + ); + assert.equal( + prepared.find((r) => r.action === "start").desktop, + "destination", + ); + await click("Retry same request"); + assert.equal(prepared.filter((r) => r.action === "start").length, 1); + const starts = sent.filter((r) => r.action === "start"); + assert.equal(starts[0], starts[1]); + await select("Runtime configuration", "source:other:revision-1"); + await click("Switch runtime configuration"); + assert.match(document.body.textContent, /destination was not started/); + const count = prepared.length; + stop = "stopped"; + await React.act(async () => {}); + assert.equal(prepared.length, count, "late Stop cannot resume failed Move"); + assert.doesNotMatch(document.body.textContent, /Retry same request/); + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.now() }); + await click("Refresh configurations"); + await select("Runtime configuration", "source:config:revision-1"); + await React.act(async () => t.mock.timers.tick(31_000)); + assert.equal( + document.querySelector('[aria-label="Runtime configuration"]').options + .length, + 1, + ); + assert.equal( + [...document.querySelectorAll("button")].find( + (b) => b.textContent === "Start on destination", + ).disabled, + true, + ); + assert.equal( + [...document.querySelectorAll("button")].find( + (b) => b.textContent === "Switch runtime configuration", + ).disabled, + true, + ); + assert.equal( + prepared.filter((r) => r.action === "start").length, + 1, + "expiry never dispatches Start", + ); + } finally { + await React.act(async () => root.unmount()); + client.clear(); + relayClient.fetchEvents = originals.fetch; + relayClient.publishEvent = originals.publish; + dom.window.close(); + } +}); + +test("terminal receiver failure is a scope-owned notification, not pre-shell layout", async () => { + const originalRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const originals = { + fetch: relayClient.fetchEvents, + subscribe: relayClient.subscribeLive, + }; + let readiness; + let closed = 0; + let rejectLate; + let delayed = false; + let subscribed = 0; + relayClient.fetchEvents = async () => { + if (delayed) + return new Promise((_, reject) => { + rejectLate = reject; + }); + return []; + }; + relayClient.subscribeLive = async ( + _filter, + _event, + _onReadiness, + _timeout, + options, + ) => { + subscribed++; + readiness = options.onState; + readiness("eose"); + return () => { + closed++; + }; + }; + window.__TAURI_INTERNALS__ = { + invoke: async () => {}, + }; + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const warnings = () => + toast + .getToasts() + .filter((t) => String(t.title).startsWith("Desktop lifecycle")); + try { + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal( + document.getElementById("root").childElementCount, + 0, + "startup must not render in-flow failure UI", + ); + assert.equal(warnings().length, 0); + await React.act(async () => + readiness("closed", { classification: "terminal", retryAfterMs: 0 }), + ); + assert.equal(warnings().length, 1); + assert.equal( + warnings()[0].title, + "Desktop lifecycle receiver subscription closed. Retry the receiver to accept new requests.", + ); + assert.equal(warnings()[0].duration, Infinity); + assert.equal(warnings()[0].closeButton, true); + readiness("closed", { classification: "terminal", retryAfterMs: 0 }); + assert.equal( + warnings().length, + 1, + "repeated failures update one notification", + ); + const retryAction = warnings()[0].action; + assert.equal(retryAction.label, "Retry receiver"); + await React.act(async () => retryAction.onClick()); + assert.equal(subscribed, 2, "explicit recovery starts a new live receiver"); + assert.equal( + warnings().length, + 0, + "successful recovery removes its warning", + ); + await React.act(async () => + root.render( + React.createElement(DesktopLifecycleReceiver, { scope: null }), + ), + ); + assert.equal(warnings().length, 0, "leaving the scope removes its warning"); + readiness("closed", { classification: "terminal", retryAfterMs: 0 }); + assert.equal( + warnings().length, + 0, + "retired receiver cannot notify another scope", + ); + delayed = true; + await React.act(async () => + root.render(React.createElement(DesktopLifecycleReceiver, { scope })), + ); + assert.equal(typeof rejectLate, "function"); + await React.act(async () => root.unmount()); + await React.act(async () => rejectLate(new Error("late failure"))); + assert.equal( + warnings().length, + 0, + "late startup rejection must not recreate the warning", + ); + assert.equal( + closed, + 3, + "failed, recovered and retired subscriptions are released", + ); + } finally { + await React.act(async () => root.unmount()); + relayClient.fetchEvents = originals.fetch; + relayClient.subscribeLive = originals.subscribe; + for (const warning of warnings()) toast.dismiss(warning.id); + globalThis.requestAnimationFrame = originalRaf; + dom.window.close(); + } +}); + +for (const interruption of ["cancel", "account", "community", "unmount"]) { + test(`mounted ${interruption} retires a pending Stop and never starts on late confirmation`, async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const query = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + query.setQueryData( + ["relay-agents"], + [{ pubkey: "agent", name: "Agent", ownerPubkey: "owner" }], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + }; + const prepared = []; + let confirmStop; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + if (command === "observe_desktop_placement") return; + if (command === "read_desktop_placement") + return ["source", "selection"]; + if (command.startsWith("prepare_desktop_")) { + const request = { + id: `request-${prepared.length}`, + kind: command.endsWith("_stop") ? 50180 : 50182, + ...args, + }; + prepared.push(request); + return request; + } + if (command === "read_desktop_lifecycle_results") + return probeResult(args.request) ?? { outcome: "running" }; + if (command === "read_desktop_stop_results") + return new Promise((resolve) => { + confirmStop = resolve; + }); + throw Error(command); + }, + }; + relayClient.fetchEvents = async () => []; + relayClient.publishEvent = async (_event, _timeout, _failure, check) => + check(); + const root = createRoot(document.getElementById("root")); + const scope = { owner: "owner", community: "wss://one.example" }; + const render = (nextScope) => + root.render( + React.createElement( + QueryClientProvider, + { client: query }, + React.createElement(DesktopLifecycleControl, { + scope: nextScope, + desktops: [ + { id: "source", name: "Source" }, + { id: "target", name: "Target" }, + ], + }), + ), + ); + const click = async (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + const select = async (label, value) => + React.act(async () => { + const element = document.querySelector(`select[aria-label="${label}"]`); + element.value = value; + element.dispatchEvent( + new dom.window.Event("change", { bubbles: true }), + ); + }); + try { + await React.act(async () => render(scope)); + await select("Agent to place", "agent"); + await select("Runtime configuration", "target:config:revision-1"); + await click("Switch runtime configuration"); + assert.equal(typeof confirmStop, "function"); + if (interruption === "cancel") await click("Cancel waiting"); + else if (interruption === "unmount") + await React.act(async () => root.unmount()); + else + await React.act(async () => + render({ + ...scope, + [interruption === "account" ? "owner" : "community"]: "changed", + }), + ); + await React.act(async () => confirmStop("stopped")); + assert.equal(prepared.filter((r) => r.action === "start").length, 0); + assert.doesNotMatch( + document.body.textContent, + /Retry same request|Source Stop confirmed/, + ); + if (interruption === "cancel") + assert.match( + document.body.textContent, + /Dispatched operations may still finish/, + ); + if (interruption === "account" || interruption === "community") + assert.equal( + document.querySelector('select[aria-label="Agent to place"]').value, + "", + ); + } finally { + await React.act(async () => root.unmount()); + query.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + dom.window.close(); + } + }); +} diff --git a/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx new file mode 100644 index 00000000000..f94f848b150 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopLifecycleControl.tsx @@ -0,0 +1,359 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import type { RelayEvent } from "@/shared/api/types"; +import type { DesktopRow, DesktopScope } from "../desktopList"; +import { + lifecycleClient, + type LifecycleOutcome, + type ConfigurationChoice, +} from "../desktopLifecycle"; +import { ownLifecycleReceiver } from "../desktopLifecycleReceiver"; +import { useRelayAgentsQuery } from "../hooks"; + +export function DesktopLifecycleReceiver({ + scope, +}: { + scope: DesktopScope | null; +}) { + const { owner, community } = scope ?? {}; + const [attempt, retry] = useState(0); + useEffect(() => { + if (!owner || !community) return; + let active = true; + let stop = () => {}; + let notification: string | number | undefined; + const reportError = (message: string) => { + if (!active) return; + // Startup mounts before the app shell: failure UI must not participate + // in layout or displace the fixed macOS window controls. Keep one visible + // notification for this receiver, and retire it with its owner/scope. + notification = toast.error(message, { + id: notification, + duration: Infinity, + closeButton: true, + action: { + label: "Retry receiver", + onClick: () => { + if (!active) return; + active = false; + stop(); + retry(attempt + 1); + }, + }, + }); + }; + stop = ownLifecycleReceiver({ owner, community }, reportError, () => { + if (active && notification !== undefined) toast.dismiss(notification); + }); + return () => { + active = false; + stop(); + if (notification !== undefined) toast.dismiss(notification); + }; + }, [owner, community, attempt]); + return null; +} +function message(outcome: LifecycleOutcome) { + switch (outcome) { + case "running": + return "Desktop confirmed a running local process. This does not prove model readiness."; + case "provisioning_unavailable": + return "Destination cannot launch this agent. No new process was started."; + case "stopped": + return "Desktop reports the agent stopped."; + case "ineligible": + return "The exact configuration is missing, changed, or unavailable. No successful launch was confirmed."; + case "different_configuration": + return "A different or unknown configuration is running. Use Switch runtime configuration."; + case "failed": + return "Desktop rejected or failed the operation. No successful launch was confirmed."; + default: + return "Operation unconfirmed. A dispatched effect may still finish; no automatic retry will run."; + } +} +/** Launch choices come from owner-private destination readiness, never host inventory. */ +export function DesktopLifecycleControl({ + scope, + desktops, +}: { + scope: DesktopScope; + desktops: DesktopRow[]; +}) { + const agents = useRelayAgentsQuery(); + const [agent, setAgent] = useState(""); + const [destination, setDestination] = useState(""); + const [catalog, setCatalog] = useState([]); + const [actual, setActual] = useState("Running configuration is unknown."); + const [refresh, setRefresh] = useState(0); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(""); + const [request, setRequest] = useState(null); + const active = useRef(true); + const generation = useRef(0); + useEffect(() => { + active.current = Boolean(scope.owner && scope.community); + setAgent(""); + setDestination(""); + setRequest(null); + setStatus(""); + setBusy(false); + return () => { + active.current = false; + generation.current++; + }; + }, [scope.owner, scope.community]); + // biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly requests a new read without changing its scope. + useEffect(() => { + setCatalog([]); + setDestination(""); + setActual("Running configuration is unknown."); + if (!agent) return; + const token = ++generation.current; + const valid = () => active.current && generation.current === token; + const client = lifecycleClient( + { owner: scope.owner, community: scope.community }, + valid, + ); + setBusy(true); + setStatus("Checking destination configurations…"); + void client + .inspect( + agent, + desktops.map((d) => d.id), + ) + .then((states) => { + client.check(); + setActual( + states + .map( + (state) => + `${desktops.find((d) => d.id === state.desktop)?.name ?? "Desktop"}: ${state.outcome === "running" ? (state.configuration ? `running configuration ${state.configuration.id}, revision ${state.configuration.revision}` : "running configuration unknown") : state.outcome === "stopped" ? "stopped" : "running state unknown"}`, + ) + .join(" · "), + ); + }) + .catch(() => { + if (valid()) setActual("Running configuration is unknown."); + }); + void client + .catalog( + agent, + desktops.map((d) => d.id), + ) + .then((entries) => { + client.check(); + setCatalog(entries); + setStatus( + entries.length + ? "Choose a configuration for the next Start. This does not change an existing process." + : "No eligible configurations reported. Existing Stop controls remain available.", + ); + }) + .catch(() => { + if (valid()) + setStatus( + "Configuration discovery is unavailable; no launch choices are offered.", + ); + }) + .finally(() => { + if (valid()) setBusy(false); + }); + return () => { + generation.current++; + }; + }, [agent, scope.owner, scope.community, desktops, refresh]); + useEffect(() => { + if (!catalog.length) return; + const expires = Math.min(...catalog.map((entry) => entry.validUntil)); + const timer = setTimeout( + () => { + setCatalog((entries) => + entries.filter((entry) => entry.validUntil > Date.now() / 1000), + ); + }, + Math.max(0, expires * 1000 - Date.now()), + ); + return () => clearTimeout(timer); + }, [catalog]); + const selected = catalog.find( + (entry) => + `${entry.host}:${entry.configuration.id}:${entry.configuration.revision}` === + destination, + ); + const run = async (action: "start" | "move" | "retry") => { + const token = ++generation.current; + const valid = () => active.current && generation.current === token; + const client = lifecycleClient(scope, valid); + setBusy(true); + setStatus("Checking authenticated Desktop state…"); + setActual("Running configuration is unknown; refresh after the operation."); + if (action !== "retry") setRequest(null); + try { + if (action === "move") { + const outcome = await client.move( + agent, + selected?.host ?? "", + desktops.map((d) => d.id), + (stage) => { + if (valid()) setStatus(stage); + }, + selected?.configuration ?? { id: "", revision: "" }, + ); + client.check(); + setStatus(message(outcome)); + } else { + const next = + action === "retry" + ? request + : selected + ? await client.start(selected.host, agent, selected.configuration) + : null; + if (!next) throw new Error("No request to retry"); + client.check(); + setRequest(next); + setStatus("Request sent. Waiting for the Desktop’s actual result…"); + const outcome = await client.send(next); + client.check(); + setStatus(message(outcome)); + } + } catch (error) { + if (valid()) + setStatus( + error instanceof Error ? error.message : "Operation unconfirmed", + ); + } finally { + if (valid()) setBusy(false); + } + }; + const reset = () => { + setRequest(null); + setStatus(""); + }; + return ( +
+

Switch runtime configuration

+ +

+ {actual} +

+ + +

+ Start may overlap with an agent still running elsewhere until that + Desktop reconnects. Switch checks the target, confirms Stop, then starts + fresh—even on the same Desktop. No files, settings, sessions, or keys + transfer. +

+
+ + + {busy && ( + + )} + {request && ( + + )} +
+ {status && ( +

+ {status} +

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/DesktopStopControl.test.mjs b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs new file mode 100644 index 00000000000..d7d8b1dfc1f --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + DesktopStopControl, + DesktopStopReceiver, +} from "./DesktopStopControl.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +test("mounted Stop waits for a correlated result and retries identical bytes without replay", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const scope = { owner: "owner", community: "wss://one.example" }; + const request = { id: "request", kind: 50180, tags: [["d", "desktop"]] }; + const result = { id: "result", kind: 50181, tags: [["e", request.id]] }; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + subscribe: relayClient.subscribeLive, + }; + let live, release; + let receiveCalls = 0, + prepared = 0, + closed = 0; + const sent = []; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "prepare_desktop_stop") { + prepared++; + assert.equal(args.desktop, "desktop"); + assert.equal(args.agent, "agent"); + return request; + } + if (command === "receive_desktop_stop") { + receiveCalls++; + return result; + } + assert.equal(command, "read_desktop_stop_results"); + return "stopped"; + }, + }; + relayClient.subscribeLive = async (filter, callback) => { + assert.deepEqual(filter, { + kinds: [50180], + authors: [scope.owner], + limit: 0, + }); + live = callback; + return () => { + closed++; + live = undefined; + }; + }; + relayClient.publishEvent = async (event) => { + sent.push(event); + live?.(event); + }; + relayClient.fetchEvents = async (filter) => { + assert.deepEqual(filter, { + kinds: [50181], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + return new Promise((resolve) => { + release = () => resolve([result]); + }); + }; + const root = createRoot(document.getElementById("root")); + const render = (receiver) => + React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + React.Fragment, + null, + receiver + ? React.createElement(DesktopStopReceiver, { scope }) + : null, + React.createElement(DesktopStopControl, { + scope, + desktop: { id: "desktop", name: "Workshop" }, + }), + ), + ), + ), + ); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + try { + await render(false); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + const select = document.querySelector("select"); + await React.act(async () => { + select.value = "agent"; + select.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + await click("Stop on Workshop"); + assert.equal(prepared, 1); + assert.match(document.body.textContent, /Waiting for this Desktop/); + assert.doesNotMatch(document.body.textContent, /Stop confirmed/); + assert.equal(receiveCalls, 0, "absent receiver has not stopped anything"); + await React.act(async () => release()); + assert.match(document.body.textContent, /Stop confirmed by Workshop/); + await render(true); + assert.equal(receiveCalls, 0, "mount cannot replay stored Stop"); + // The mounted receiver returns a saved native result, while the sender + // explicitly retries the exact prepared request rather than signing anew. + relayClient.publishEvent = async (event) => { + sent.push(event); + if (event.kind === 50180) live?.(event); + }; + await click("Retry same Stop"); + await React.act(async () => release()); + assert.equal(prepared, 1); + assert.equal(receiveCalls, 1); + assert.ok(sent.filter((e) => e.kind === 50180).every((e) => e === request)); + } finally { + await React.act(async () => root.unmount()); + assert.equal(closed, 1); + client.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + relayClient.subscribeLive = original.subscribe; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopStopControl.tsx b/desktop/src/features/agents/ui/DesktopStopControl.tsx new file mode 100644 index 00000000000..bd5e8f56cd7 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "react"; +import { useRelayAgentsQuery } from "../hooks"; +import { + prepareStop, + readStopOutcome, + receiveStops, + sendStop, +} from "../desktopStop"; +import type { DesktopScope, DesktopRow } from "../desktopList"; +import type { RelayEvent } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; + +/** App-scoped live receiver; no historical requests are loaded on mount. */ +export function DesktopStopReceiver({ scope }: { scope: DesktopScope | null }) { + const [error, setError] = useState(""); + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + setError(""); + void receiveStops({ owner, community }, () => active, setError) + .then((unsubscribe) => { + if (active) close = unsubscribe; + else unsubscribe(); + }) + .catch(() => { + if (active) + setError("Remote Stop receiver is unavailable on this Desktop."); + }); + return () => { + active = false; + close?.(); + }; + }, [owner, community]); + return error ? ( +

+ {error} +

+ ) : null; +} + +/** Deliberately selects a host, not an inferred running location or presence. */ +export function DesktopStopControl({ + scope, + desktop, +}: { + scope: DesktopScope; + desktop: DesktopRow; +}) { + const agents = useRelayAgentsQuery(); + const owned = (agents.data ?? []).filter( + (agent) => agent.ownerPubkey === scope.owner, + ); + const [agent, setAgent] = useState(""); + const [request, setRequest] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + }; + }, []); + const run = async (retry: boolean) => { + setBusy(true); + let current = retry ? request : null; + try { + current ??= await prepareStop( + scope, + desktop.id, + agent, + () => active.current, + ); + if (!active.current) return; + setRequest(current); + setMessage("Stop requested. Waiting for this Desktop’s result…"); + try { + await sendStop(scope, current, () => active.current); + } catch { + if (active.current) + setMessage( + "Delivery unconfirmed. Checking for this Desktop’s result…", + ); + } + for (let attempt = 0; attempt < 15 && active.current; attempt++) { + const outcome = await readStopOutcome( + scope, + current, + () => active.current, + ); + if (!active.current) return; + if (outcome === "stopped") { + setMessage(`Stop confirmed by ${desktop.name}.`); + return; + } + if (outcome === "failed") { + setMessage( + `Stop failed on ${desktop.name}. No success was confirmed.`, + ); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (active.current) + setMessage( + "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.", + ); + } catch { + if (active.current) + setMessage("Stop unconfirmed. No successful result could be read."); + } finally { + if (active.current) setBusy(false); + } + }; + return ( +
+ +

+ Stops only this agent on this Desktop in this community. This list does + not establish where it is running. +

+ {agents.isError &&

Your agent list is unavailable.

} + + {request && ( + + )} + {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/KnownDesktops.tsx b/desktop/src/features/agents/ui/KnownDesktops.tsx new file mode 100644 index 00000000000..5f4328b332c --- /dev/null +++ b/desktop/src/features/agents/ui/KnownDesktops.tsx @@ -0,0 +1,221 @@ +import { + DesktopLifecycleControl, + DesktopLifecycleReceiver, +} from "./DesktopLifecycleControl"; +import { DesktopStopControl } from "./DesktopStopControl"; +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { relayClient } from "@/shared/api/relayClient"; +import { Button } from "@/shared/ui/button"; +import { refreshDesktopList, type DesktopList } from "../desktopList"; +import { + DESKTOP_PULSE_MS, + desktopFreshness, + useDesktopObservations, + type DesktopObservation, +} from "../desktopObservations"; + +import { + useDesktopCapabilities, + type DesktopCapabilities, +} from "../desktopCapabilities"; +import { DesktopCapabilityDetails } from "./DesktopCapabilityDetails"; + +type View = { + scope?: import("../desktopList").DesktopScope; + capabilities?: DesktopCapabilities[]; + capabilityWarning?: string; + list: DesktopList | null; + error: boolean; + loading: boolean; + refresh: () => void; + observations?: DesktopObservation[]; + observationWarning?: string; + now?: number; +}; + +function useDesktopScope() { + const owner = useIdentityQuery().data?.pubkey; + const { activeCommunity } = useCommunities(); + const community = activeCommunity?.relayUrl + .trim() + .replace(/^http/, "ws") + .replace(/\/+$/, ""); + return owner && community ? { owner, community } : null; +} + +function useDesktopList() { + const scope = useDesktopScope(); + const { owner, community } = scope ?? {}; + return useQuery({ + queryKey: ["desktop-profiles", owner, community], + enabled: !!owner && !!community, + queryFn: ({ signal }) => { + if (!owner || !community) throw new Error("Desktop scope unavailable"); + return refreshDesktopList({ owner, community }, () => !signal.aborted); + }, + // Last observer removal cancels and evicts decrypted data, including on an + // account switch within the same community. No previous-key placeholder. + gcTime: 0, + retry: false, + refetchOnWindowFocus: false, + }); +} + +/** Startup and Agents share the existing owner/community query cache. */ +export function DesktopListStartup() { + const [epoch, setEpoch] = useState(0); + const { refetch } = useDesktopList(); + const { refetch: pulse } = useDesktopObservations(useDesktopScope()); + const { refetch: report } = useDesktopCapabilities(useDesktopScope()); + useEffect(() => { + const timer = setInterval(() => { + void pulse(); + void report(); + }, DESKTOP_PULSE_MS); + const unsubscribe = relayClient.subscribeToReconnects(() => { + setEpoch((n) => n + 1); + void refetch(); + void pulse(); + void report(); + }); + return () => { + clearInterval(timer); + unsubscribe(); + }; + }, [refetch, pulse, report]); + const scope = useDesktopScope(); + return ; +} + +export function KnownDesktops() { + const query = useDesktopList(); + const observations = useDesktopObservations(useDesktopScope()); + const capabilities = useDesktopCapabilities(useDesktopScope()); + const [now, setNow] = useState(() => Date.now() / 1000); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now() / 1000), 30_000); + return () => clearInterval(timer); + }, []); + return ( + { + void query.refetch(); + void observations.refetch(); + void capabilities.refetch(); + }} + /> + ); +} + +export function DesktopListView({ + scope, + list, + loading, + error, + refresh, + observations, + observationWarning, + capabilities, + capabilityWarning, + now = Date.now() / 1000, +}: View) { + return ( +
+
+

Known Desktops

+ +
+

+ Private to you. Saved profiles do not indicate whether a Desktop is + online or ready to run agents. Last heard is a Desktop observation, not + proof that its agents are running or stopped. +

+ {loading &&

Loading Desktop profiles…

} + {error && ( +

+ Desktop profiles unavailable. Previously loaded profiles are retained. +

+ )} + {capabilityWarning &&

{capabilityWarning}

} + {observationWarning &&

{observationWarning}

} + {list?.warning &&

{list.warning}

} + {list?.partial && ( +

Partial list: showing up to 100 profiles.

+ )} + {list && !list.rows.length && !error &&

No Desktop profiles found.

} + {scope && list && ( + + )} +
    + {list?.rows.map((row) => ( +
  • + {row.name} + {row.id === list.local && " · This Desktop"} +
    + Profile updated{" "} + +
    +
    + Last heard:{" "} + {desktopFreshness( + observations?.find((item) => item.id === row.id)?.heard, + now, + )} +
    + {scope && ( + + )} + item.id === row.id)} + now={now} + /> +
  • + ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/RuntimeConfigurations.test.mjs b/desktop/src/features/agents/ui/RuntimeConfigurations.test.mjs new file mode 100644 index 00000000000..c5c301941d0 --- /dev/null +++ b/desktop/src/features/agents/ui/RuntimeConfigurations.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { RuntimeConfigurationEditor } from "./RuntimeConfigurations.tsx"; + +const reference = (id, revision = "old") => ({ id, revision }); +const entry = (id) => ({ + ...reference(id), + name: id, + host: "host", + runtime: "buzz-agent", + model: `model-${id}`, + provider: "openai", + workspace: null, + credentialRefs: {}, +}); +const initial = () => ({ + configurations: { + selected: "A", + entries: [entry("A"), entry("B"), entry("unavailable")], + }, + catalog: ["A", "B", "unavailable"].map((id) => ({ + configuration: reference(id), + name: id, + runtime: "buzz-agent", + model: `model-${id}`, + eligible: id !== "unavailable", + })), + host: "host", + updatedAt: "before", + running: reference("A"), +}); + +test("mounted local configs save next selection without restarting, then start the exact saved revision", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let current = initial(); + const calls = []; + let refuse = false; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + calls.push({ command, args }); + assert.equal(args.owner, "owner"); + assert.equal(args.community, "wss://one.test"); + assert.equal(args.agent, "agent"); + if (command === "get_runtime_configurations") + return structuredClone(current); + if (command === "save_runtime_configurations") { + assert.equal(args.expectedUpdatedAt, current.updatedAt); + current = { + ...current, + configurations: args.configurations, + updatedAt: "saved", + }; + return structuredClone(current); + } + if (command === "start_runtime_configuration") { + if (refuse) throw Error("missing prerequisite"); + current = { ...current, running: args.configuration }; + return {}; + } + throw Error(command); + }, + }; + const click = (text) => + React.act(async () => { + const button = [...document.querySelectorAll("button")].find( + (b) => b.textContent === text, + ); + assert.ok(button, text); + assert.equal(button.disabled, false, text); + button.click(); + }); + try { + await React.act(async () => + root.render( + React.createElement(RuntimeConfigurationEditor, { + scope: { owner: "owner", community: "wss://one.test" }, + agent: "agent", + runtimes: [], + }), + ), + ); + const picker = document.querySelector( + 'select[aria-label="Next runtime configuration"]', + ); + assert.deepEqual( + [...picker.options].map((o) => o.value), + ["A", "B"], + ); + await React.act(async () => { + picker.value = "B"; + picker.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + assert.equal( + calls.filter((c) => c.command === "start_runtime_configuration").length, + 0, + ); + assert.match(document.body.textContent, /Next Start: B/); + assert.match(document.body.textContent, /Running revision: A \/ old/); + refuse = true; + await click("Start configuration"); + assert.match( + document.body.textContent, + /no new running configuration was confirmed/, + ); + assert.match(document.body.textContent, /Running revision: A \/ old/); + assert.deepEqual(calls.at(-1).args.configuration, reference("B")); + refuse = false; + await click("Start configuration"); + assert.match(document.body.textContent, /Running revision: B \/ old/); + await click("Edit B"); + await click("Cancel configuration edit"); + assert.equal( + calls.filter((c) => c.command === "save_runtime_configurations").length, + 1, + ); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + } +}); + +test("late configuration read after unmount does not display another scope's settings", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let finish; + window.__TAURI_INTERNALS__ = { + invoke: () => + new Promise((resolve) => { + finish = resolve; + }), + }; + await React.act(async () => + root.render( + React.createElement(RuntimeConfigurationEditor, { + scope: { owner: "owner", community: "wss://one.test" }, + agent: "agent", + runtimes: [], + }), + ), + ); + await React.act(async () => root.unmount()); + await React.act(async () => finish(initial())); + assert.equal(document.body.textContent, ""); + dom.window.close(); +}); diff --git a/desktop/src/features/agents/ui/RuntimeConfigurations.tsx b/desktop/src/features/agents/ui/RuntimeConfigurations.tsx new file mode 100644 index 00000000000..a61539c3b60 --- /dev/null +++ b/desktop/src/features/agents/ui/RuntimeConfigurations.tsx @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useAcpRuntimesQuery, useManagedAgentsQuery } from "../hooks"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { DesktopScope } from "../desktopList"; +import { Button } from "@/shared/ui/button"; + +export type ConfigurationRef = { id: string; revision: string }; +type Configuration = ConfigurationRef & { + name: string; + host: string; + runtime: string; + model: string; + provider: string | null; + workspace: string | null; + credentialRefs: Record; +}; +type ConfigurationSet = { selected: string | null; entries: Configuration[] }; +export type ConfigurationView = { + configurations: ConfigurationSet; + updatedAt: string; + host: string; + running: ConfigurationRef | null; + catalog: { + configuration: ConfigurationRef | null; + name: string; + runtime: string; + model: string | null; + eligible: boolean; + }[]; +}; + +/** Management is local and owner-scoped; lifecycle Stop remains an independent control. */ +export function RuntimeConfigurations() { + const owner = useIdentityQuery().data?.pubkey; + const { activeCommunity } = useCommunities(); + const community = activeCommunity?.relayUrl + .trim() + .replace(/^http/, "ws") + .replace(/\/+$/, ""); + const agents = useManagedAgentsQuery(); + const runtimes = useAcpRuntimesQuery(); + const [agent, setAgent] = useState(""); + if (!owner || !community) return null; + const local = agents.data?.filter((a) => a.backend.type === "local") ?? []; + return ( +
+

Runtime configurations

+

+ Named settings on this Desktop. Keys stay here; edits apply only on the + next Start. +

+ + {agent && local.some((a) => a.pubkey === agent) && ( + + )} +
+ ); +} + +/** Exported mounted seam: scope remount retires every asynchronous continuation. */ +export function RuntimeConfigurationEditor({ + scope, + agent, + runtimes, +}: { + scope: DesktopScope; + agent: string; + runtimes: AcpRuntimeCatalogEntry[] | undefined; +}) { + const [view, setView] = useState(null); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + const args = { ...scope, agent }; + useEffect(() => { + const token = ++generation.current; + invoke("get_runtime_configurations", { + owner: scope.owner, + community: scope.community, + agent, + }).then( + (next) => { + if (token === generation.current) setView(next); + }, + () => { + if (token === generation.current) + setError("Configurations could not be loaded. Retry."); + }, + ); + return () => { + generation.current++; + }; + }, [scope.owner, scope.community, agent]); + async function perform(work: () => Promise, success = "") { + const token = ++generation.current; + setBusy(true); + setError(""); + setNotice(""); + try { + const next = await work(); + if (token !== generation.current) return; + setView(next); + setDraft(null); + setNotice(success); + } catch { + if (token === generation.current) + setError( + "Operation failed or settings changed. Reload and retry; no new running configuration was confirmed.", + ); + } finally { + if (token === generation.current) setBusy(false); + } + } + const reload = () => + invoke("get_runtime_configurations", args); + const save = (configurations: ConfigurationSet) => + perform( + () => + invoke("save_runtime_configurations", { + ...args, + expectedUpdatedAt: view?.updatedAt, + configurations, + }), + "Saved for next Start. Any running process is unchanged.", + ); + const selected = view?.configurations.entries.find( + (c) => c.id === view.configurations.selected, + ); + const eligible = + view?.catalog.some( + (c) => c.configuration?.id === selected?.id && c.eligible, + ) ?? false; + const runtime = runtimes?.find((r) => r.id === draft?.runtime); + function add() { + if (!view) return; + setDraft({ + id: crypto.randomUUID(), + revision: crypto.randomUUID(), + name: "", + host: view.host, + runtime: "", + model: "", + provider: null, + workspace: null, + credentialRefs: {}, + }); + } + return ( +
+ {error &&

{error}

} + {notice &&

{notice}

} + + {view && ( + <> +

+ Next Start:{" "} + {selected + ? `${selected.name} · ${selected.runtime} · ${selected.model}` + : "Default (inherited settings)"} +

+

+ Running revision:{" "} + {view.running + ? `${view.running.id} / ${view.running.revision}` + : "No named launch reported"} +

+ + {!eligible && ( +

+ Start unavailable. Check this agent’s local key, runtime, model + and provider setup, then reload. Existing Stop controls remain + available. +

+ )} + + +
    + {view.configurations.entries.map((c) => ( +
  • + {c.name} · {c.runtime} · {c.model} + + +
  • + ))} +
+ {!runtimes && ( +

+ Runtime catalog unavailable or loading. Reload the catalog before + editing. +

+ )} + {draft && ( +
+ Configuration on this Desktop + + + {runtime?.providerEnvVar && ( + + )} + + +

+ Uses independently provisioned local credentials. No keys are + copied or entered here. An unavailable model fails instead of + silently substituting another. +

+ + +
+ )} + + )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 9c06044c5a9..5f5cb277457 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -433,10 +433,11 @@ export function useManagedAgentActions() { stopMutation.isPending || startOnLaunchMutation.isPending || deleteMutation.isPending; - const startingAgentPubkey = - startMutation.isPending && typeof startMutation.variables === "string" + const startingAgentPubkey = startMutation.isPending + ? typeof startMutation.variables === "string" ? startMutation.variables - : null; + : (startMutation.variables?.pubkey ?? null) + : null; return { relayAgentsQuery, diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index ced4836d6b4..63a64899b61 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -176,6 +176,7 @@ export function useMembersSidebarActions({ action, pubkey: agent.pubkey, relayUrl, + explicitStart: action === "start", }); setActionNoticeMessage( action === "stop" diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 745df71768d..0b2e79ab6e5 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -28,7 +28,9 @@ export function useAgentLifecycleActions({ channels: readonly Channel[] | undefined; managedAgent: ManagedAgent | undefined; relayAgents: readonly RelayAgent[] | undefined; - startManagedAgent: (pubkey: string) => Promise; + startManagedAgent: ( + input: import("@/features/agents/lib/managedAgentControlActions").ManagedAgentStartInput, + ) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { const handleAgentPrimaryAction = React.useCallback(async () => { diff --git a/desktop/src/protectedFeatures/bestie/useBestie.ts b/desktop/src/protectedFeatures/bestie/useBestie.ts index d724023a793..05302b9eeaa 100644 --- a/desktop/src/protectedFeatures/bestie/useBestie.ts +++ b/desktop/src/protectedFeatures/bestie/useBestie.ts @@ -8,7 +8,7 @@ import { useManagedAgentRuntimesQuery, } from "@/features/agents/managedAgentRuntimeHooks"; import { - canonicalRelayUrl, + canonicalBestieRelayUrl, findManagedAgentRuntime, managedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; @@ -36,7 +36,7 @@ export function bestieAssignmentQueryKey( ) { return [ "bestie-assignment", - canonicalRelayUrl(relayUrl) ?? relayUrl, + canonicalBestieRelayUrl(relayUrl) ?? relayUrl, ownerPubkey.toLowerCase(), ] as const; } diff --git a/desktop/src/shared/api/relayClientPublishRejection.test.mjs b/desktop/src/shared/api/relayClientPublishRejection.test.mjs index 7875b8679ab..f323623ef6f 100644 --- a/desktop/src/shared/api/relayClientPublishRejection.test.mjs +++ b/desktop/src/shared/api/relayClientPublishRejection.test.mjs @@ -293,3 +293,52 @@ test("a community switch after send failure cannot retry through its replacement ); assert.equal(eventFrames().length, 0); }); + +test("Desktop pulse cancellation fences rate-limit and reconnect continuations", async () => { + const { refreshDesktopObservations } = await import( + "../../features/agents/desktopObservations.ts" + ); + for (const boundary of ["rate-limit", "reconnect"]) { + reset(); + const client = connectedClient(); + const scope = { owner: "owner", community: "wss://a.example" }; + let active = true; + const reconnect = deferred(); + let reconnecting = false; + client.ensureConnected = async () => { + reconnecting = true; + await reconnect.promise; + client.wsId = 8; + return client.connectionGeneration; + }; + if (boundary === "rate-limit") activateRateLimit(4); + else + sendTransport = async () => { + throw Error("socket failed"); + }; + const result = refreshDesktopObservations( + scope, + () => active, + async (command) => { + assert.equal(command, "prepare_desktop_observation"); + return { event: { id: "pulse", kind: 30181 } }; + }, + client, + ); + const rejected = assert.rejects(result, /scope changed/); + if (boundary === "reconnect") await flushUntil(() => reconnecting); + else await Promise.resolve(); + active = false; + const generation = client.connectionGeneration; + resetRateLimitGate(); + reconnect.resolve(); + await rejected; + assert.equal(eventFrames().length, 0, boundary); + assert.equal(client.pendingEvents.size, 0); + assert.equal( + client.connectionGeneration, + generation, + "cancellation is not socket failure", + ); + } +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 95bcec79700..5ba009e6476 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -16,6 +16,7 @@ import { getTextPayload, toRelayFrames, type ConnectionState, + type LiveSubscriptionOptions, type LiveSubscriptionReadiness, type PendingEvent, type RelaySubscription, @@ -111,6 +112,15 @@ export class RelayClient { setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } + /** Scope epoch changes before a community or identity transport is replaced. */ + getSessionEpoch() { + return this.sessionEpoch; + } + + /** Invalidates one-shot lifecycle coordinators on a transport interruption. */ + getConnectionGeneration() { + return this.connectionGeneration; + } disconnect() { const error = new Error("Relay disconnected for community switch."); @@ -154,6 +164,12 @@ export class RelayClient { sub.reject(error); } else { clearClosedRetry(sub); + sub.resolveReady?.("closed"); + sub.onState?.("closed", { + classification: "terminal", + retryAfterMs: 0, + }); + sub.resolveReady = undefined; } this.subscriptions.delete(subId); } @@ -410,8 +426,15 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs?: number, + options?: LiveSubscriptionOptions, ) { - return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs); + return this.subscribe( + filter, + onEvent, + onReady, + readinessTimeoutMs, + options, + ); } async subscribeToChannelMentionEvents( channelId: string, @@ -600,6 +623,7 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs = 250, + options: LiveSubscriptionOptions = {}, ) { await this.ensureConnected(); @@ -612,22 +636,28 @@ export class RelayClient { resolve(); }; }); - const fallbackTimeout = window.setTimeout( - () => resolveReady("timeout"), - readinessTimeoutMs, - ); + const fallbackTimeout = window.setTimeout(() => { + options.onState?.("timeout"); + resolveReady("timeout"); + }, readinessTimeoutMs); - this.subscriptions.set(subId, { + const subscription: Extract = { mode: "live", filter, onEvent, resolveReady, - }); + onState: options.onState, + closedRecovery: options.closedRecovery ?? "shared", + }; + this.subscriptions.set(subId, subscription); try { await this.sendRawWithReconnectRetry( ["REQ", subId, filter], "Failed to restore relay subscription.", + () => + subscription.closedRecovery !== "explicit" || + this.subscriptions.get(subId) === subscription, ); } catch (error) { window.clearTimeout(fallbackTimeout); @@ -689,6 +719,7 @@ export class RelayClient { private async sendRawWithReconnectRetry( payload: unknown[], fallbackMessage: string, + retryStillOwned: () => boolean = () => true, ) { try { await this.sendRaw(payload); @@ -697,8 +728,13 @@ export class RelayClient { error, fallbackMessage, ); + // resetConnection may retire an explicit subscription synchronously. + // Never put its now-ownerless REQ onto the replacement connection; + // shared subscriptions retain their existing reconnect retry behavior. + if (!retryStillOwned()) throw normalizedError; try { await this.ensureConnected(); + if (!retryStillOwned()) throw normalizedError; await this.sendRaw(payload); } catch (retryError) { throw this.recoverFromSocketFailure( @@ -721,6 +757,7 @@ export class RelayClient { event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive?: () => void, ) { return publishSessionEvent( { @@ -738,6 +775,7 @@ export class RelayClient { event, timeoutMessage, sendErrorMessage, + assertActive, ); } @@ -1055,8 +1093,15 @@ export class RelayClient { continue; } subscription.resolveReady?.("closed"); + subscription.onState?.("closed", { + classification: options?.reconnect === false ? "terminal" : "retryable", + retryAfterMs: 0, + }); subscription.resolveReady = undefined; clearClosedRetry(subscription); + if (subscription.closedRecovery === "explicit") { + this.subscriptions.delete(subId); + } } for (const [eventId, pendingEvent] of this.pendingEvents) { window.clearTimeout(pendingEvent.timeout); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 8bd6379d7a9..09064dc6276 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -1,4 +1,5 @@ import type { RelayEvent } from "@/shared/api/types"; +import type { RelayClosedClass } from "@/shared/api/relayClosedPolicy"; /** * Observable connection state for the relay singleton. @@ -70,11 +71,39 @@ type FirstEventSubscription = { export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; +export type LiveSubscriptionClosedRecovery = { + classification: RelayClosedClass; + /** Minimum delay before the owner creates a fresh subscription. */ + retryAfterMs: number; +}; + +/** + * Optional lifecycle policy for a live subscription. + * + * Most subscriptions keep the shared reconnect/CLOSED recovery behavior. A + * command receiver can instead request explicit recovery: every CLOSED retires + * that subscription and `onState` remains observable after initial EOSE. The + * owner receives only a safe recovery class/delay and decides whether to create + * a fresh subscription or require deliberate retry. + */ +export type LiveSubscriptionOptions = { + onState?: ( + state: LiveSubscriptionReadiness, + closed?: LiveSubscriptionClosedRecovery, + ) => void; + closedRecovery?: "shared" | "explicit"; +}; + type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; + onState?: ( + state: LiveSubscriptionReadiness, + closed?: LiveSubscriptionClosedRecovery, + ) => void; + closedRecovery: "shared" | "explicit"; lastSeenCreatedAt?: number; /** * Lower bound of a reconnect backfill window that has not yet completed. diff --git a/desktop/src/shared/api/relayClosedRecovery.ts b/desktop/src/shared/api/relayClosedRecovery.ts index 9d30a233e7b..d249bf2406d 100644 --- a/desktop/src/shared/api/relayClosedRecovery.ts +++ b/desktop/src/shared/api/relayClosedRecovery.ts @@ -123,10 +123,27 @@ function recoverLiveSubscriptionFromClosed({ message: string; sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise; }) { + const closedClass = classifyRelayClosed(message); + + if (closedClass === "rate-limited") { + const hintSeconds = parseRateLimitHint(message); + activateRateLimit(hintSeconds); + } + subscription.resolveReady?.("closed"); + subscription.onState?.("closed", { + classification: closedClass, + retryAfterMs: closedClass === "rate-limited" ? rateLimitRemainingMs() : 0, + }); subscription.resolveReady = undefined; - const closedClass = classifyRelayClosed(message); + if (subscription.closedRecovery === "explicit") { + // Command receivers must not survive CLOSED into shared re-subscription. + // Their owner presents an explicit fresh-receiver action instead. + clearClosedRetry(subscription); + subscriptions.delete(subId); + return; + } if (closedClass === "terminal") { // Auth/access/filter failure — permanently remove the subscription so it @@ -146,9 +163,7 @@ function recoverLiveSubscriptionFromClosed({ let delayMs = backoffMs; if (closedClass === "rate-limited") { - // Activate the gate so concurrent operations back off too. const hintSeconds = parseRateLimitHint(message); - activateRateLimit(hintSeconds); // Use the gate's actual remaining time so a shorter hint arriving under a // longer active gate does not schedule a premature retry that just gets // another CLOSED. The fallback covers the gate-inactive edge case @@ -259,6 +274,7 @@ export function handleSubscriptionEose({ if (generation !== undefined) markReconnectLiveEose(subscription, generation); subscription.resolveReady?.("eose"); + subscription.onState?.("eose"); subscription.resolveReady = undefined; subscription.closedRetryAttempt = 0; clearClosedRetry(subscription); diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff719926700..6f6b6b7c824 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -19,9 +19,11 @@ export async function publishSessionEvent( event: RelayEvent, timeoutMessage: string, sendErrorMessage: string, + assertActive: () => void = () => {}, ): Promise { const publishOwnership = session.ownership(); await waitForRateLimit(); + assertActive(); if (publishOwnership !== session.ownership()) { throw new Error("Relay disconnected for community switch."); } @@ -48,6 +50,14 @@ export async function publishSessionEvent( return; } + try { + assertActive(); + } catch (error) { + window.clearTimeout(timeout); + session.pendingEvents.delete(event.id); + reject(error); + return; + } // Expected socket recovery must not reject the operation being retried. session.pendingEvents.delete(event.id); const sendError = session.recoverSocketFailure(error, sendErrorMessage); @@ -55,7 +65,9 @@ export async function publishSessionEvent( let retryGeneration: number | null = null; try { - retryGeneration = await session.reconnect(); + const reconnected = await session.reconnect(); + assertActive(); + retryGeneration = reconnected; if ( publishOwnership !== session.ownership() || session.generation() !== retryGeneration || diff --git a/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs new file mode 100644 index 00000000000..a52d408bec3 --- /dev/null +++ b/desktop/src/shared/api/relayLiveSubscriptionState.test.mjs @@ -0,0 +1,390 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +let fakeNow = 0; +let nextTimerId = 1; +const pendingTimers = new Map(); +const sentFrames = []; +const sendAttempts = []; +let failNextSend = false; + +globalThis.window = { + setTimeout: (fn, ms) => { + const id = nextTimerId++; + pendingTimers.set(id, { fn, fireAt: fakeNow + ms }); + return id; + }, + clearTimeout: (id) => pendingTimers.delete(id), + __TAURI_INTERNALS__: { + invoke: async (command, args) => { + if (command === "plugin:websocket|send") { + sendAttempts.push(args); + if (failNextSend) { + failNextSend = false; + throw new Error("fixture first send failed"); + } + sentFrames.push(args); + } + }, + }, +}; +Date.now = () => fakeNow; + +const { RelayClient } = await import("./relayClientSession.ts"); +const { receiveLifecycle } = await import("@/features/agents/desktopLifecycle"); +const { resetRateLimitGate } = await import("./relayRateLimitGate.ts"); + +function resetHarness() { + fakeNow = 0; + nextTimerId = 1; + pendingTimers.clear(); + sentFrames.length = 0; + sendAttempts.length = 0; + failNextSend = false; + resetRateLimitGate(); +} + +function connectedClient() { + const client = new RelayClient(); + client.wsId = 7; + return client; +} + +function sentProtocolFrames(type) { + return sentFrames + .map(({ message }) => JSON.parse(message.data)) + .filter((frame) => frame[0] === type); +} + +async function flushUntil(predicate, attempts = 40) { + for (let attempt = 0; attempt < attempts; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail("condition did not become true before the microtask limit"); +} + +async function flushMicrotasks(attempts = 10) { + for (let attempt = 0; attempt < attempts; attempt++) await Promise.resolve(); +} + +function tickTo(time) { + fakeNow = time; + for (;;) { + const due = [...pendingTimers.entries()].filter( + ([, timer]) => timer.fireAt <= fakeNow, + ); + if (!due.length) return; + for (const [id, timer] of due) { + if (!pendingTimers.delete(id)) continue; + timer.fn(); + } + } +} + +function deliver(client, frame) { + return client.handleWsMessage( + { type: "Text", data: JSON.stringify(frame) }, + client.connectionGeneration, + ); +} + +async function openLive(client, options, onEvent = () => {}, onReady) { + const opened = client.subscribeLive( + { kinds: [50182, 50180], authors: ["owner"], limit: 0 }, + onEvent, + onReady, + 5000, + options, + ); + await flushUntil(() => sentProtocolFrames("REQ").length > 0); + const subId = sentProtocolFrames("REQ").at(-1)[1]; + return { opened, subId }; +} + +test("persistent state reports timeout, late EOSE, then CLOSED through RelayClient", async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const readiness = []; + const { opened, subId } = await openLive( + client, + { closedRecovery: "explicit", onState: (state) => states.push(state) }, + () => {}, + (state) => readiness.push(state), + ); + + tickTo(5000); + const close = await opened; + assert.deepEqual(states, ["timeout"]); + assert.deepEqual(readiness, ["timeout"]); + + await deliver(client, ["EOSE", subId]); + await deliver(client, ["CLOSED", subId, "restricted: access revoked"]); + + assert.deepEqual(states, ["timeout", "eose", "closed"]); + assert.deepEqual(readiness, ["timeout", "eose"]); + assert.equal(client.subscriptions.has(subId), false); + await close(); +}); + +for (const [label, message, classification] of [ + ["terminal", "restricted: access revoked", "terminal"], + ["retryable", "error: storage temporarily unavailable", "retryable"], +]) { + test(`explicit recovery retires an EOSE-ready ${label} CLOSED without re-REQ`, async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const recoveries = []; + const { opened, subId } = await openLive(client, { + closedRecovery: "explicit", + onState: (state, recovery) => { + states.push(state); + if (recovery) recoveries.push(recovery); + }, + }); + await deliver(client, ["EOSE", subId]); + const close = await opened; + + await deliver(client, ["CLOSED", subId, message]); + tickTo(60_000); + await Promise.resolve(); + + assert.deepEqual(states, ["eose", "closed"]); + assert.deepEqual(recoveries, [{ classification, retryAfterMs: 0 }]); + assert.equal(client.subscriptions.has(subId), false); + assert.equal(sentProtocolFrames("REQ").length, 1); + await close(); + }); +} + +test("connection reset reports CLOSED and retires only explicit-recovery subscriptions", async () => { + resetHarness(); + const client = connectedClient(); + const explicitStates = []; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state) => explicitStates.push(state), + }); + await deliver(client, ["EOSE", explicit.subId]); + const closeExplicit = await explicit.opened; + + const ordinary = await openLive(client); + await deliver(client, ["EOSE", ordinary.subId]); + const closeOrdinary = await ordinary.opened; + + client.resetConnection(new Error("fixture connection reset")); + + assert.deepEqual(explicitStates, ["eose", "closed"]); + assert.equal(client.subscriptions.has(explicit.subId), false); + assert.equal( + client.subscriptions.has(ordinary.subId), + true, + "default live subscribers retain shared reconnect recovery", + ); + + await closeExplicit(); + await closeOrdinary(); + client.disconnect(); +}); + +test("disconnect reports terminal CLOSED before retiring an explicit subscription", async () => { + resetHarness(); + const client = connectedClient(); + const states = []; + const recoveries = []; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state, recovery) => { + states.push(state); + if (recovery) recoveries.push(recovery); + }, + }); + await deliver(client, ["EOSE", explicit.subId]); + await explicit.opened; + + client.disconnect(); + + assert.deepEqual(states, ["eose", "closed"]); + assert.deepEqual(recoveries, [ + { classification: "terminal", retryAfterMs: 0 }, + ]); + assert.equal(client.subscriptions.has(explicit.subId), false); +}); + +test("explicit rate-limited CLOSED exposes only classified recovery and gate delay", async () => { + resetHarness(); + const client = connectedClient(); + let recovery; + const explicit = await openLive(client, { + closedRecovery: "explicit", + onState: (state, detail) => { + if (state === "closed") recovery = detail; + }, + }); + await deliver(client, ["EOSE", explicit.subId]); + await explicit.opened; + + await deliver(client, [ + "CLOSED", + explicit.subId, + "rate-limited: private relay detail; retry in 4s", + ]); + + assert.deepEqual(recovery, { + classification: "rate-limited", + retryAfterMs: 4_000, + }); + assert.equal( + JSON.stringify(recovery).includes("private relay detail"), + false, + "raw relay payload must not cross the recovery contract", + ); +}); + +test("explicit retirement during first send failure prevents a fresh ownerless REQ", async () => { + resetHarness(); + const client = connectedClient(); + let ensureCalls = 0; + client.ensureConnected = async () => { + ensureCalls++; + if (ensureCalls > 1) client.wsId = 8; + return client.connectionGeneration; + }; + failNextSend = true; + + await assert.rejects( + client.subscribeLive( + { kinds: [50182], authors: ["owner"], limit: 0 }, + () => {}, + undefined, + 5_000, + { closedRecovery: "explicit" }, + ), + /fixture first send failed/, + ); + + assert.equal( + ensureCalls, + 1, + "retired subscription must not reconnect to retry", + ); + assert.equal(sendAttempts.length, 1); + assert.equal(sentProtocolFrames("REQ").length, 0); + assert.equal(client.subscriptions.size, 0); +}); + +test("ordinary subscription still retries its first failed send", async () => { + resetHarness(); + const client = connectedClient(); + let ensureCalls = 0; + client.ensureConnected = async () => { + ensureCalls++; + if (ensureCalls > 1) client.wsId = 8; + return client.connectionGeneration; + }; + failNextSend = true; + + const close = await client.subscribeLive({ kinds: [9], limit: 0 }, () => {}); + + assert.equal(ensureCalls, 2); + assert.equal(sendAttempts.length, 2); + assert.equal(sentProtocolFrames("REQ").length, 1); + await close(); + client.disconnect(); +}); + +test("default live subscribers retain shared retry after retryable CLOSED", async () => { + resetHarness(); + const client = connectedClient(); + const ordinary = await openLive(client); + await deliver(client, ["EOSE", ordinary.subId]); + const close = await ordinary.opened; + + await deliver(client, [ + "CLOSED", + ordinary.subId, + "error: storage temporarily unavailable", + ]); + assert.equal(client.subscriptions.has(ordinary.subId), true); + + tickTo(1000); + await flushUntil(() => sentProtocolFrames("REQ").length === 2); + assert.equal(client.subscriptions.has(ordinary.subId), true); + + await close(); +}); + +test("real explicit CLOSED fences queued lifecycle work and a deliberate retry is fresh", async () => { + resetHarness(); + const client = connectedClient(); + let resolveHistory; + client.fetchEvents = () => + new Promise((resolve) => { + resolveHistory = resolve; + }); + const ipcCalls = []; + const ipc = async (command) => { + ipcCalls.push(command); + return null; + }; + const errors = []; + const receiving = receiveLifecycle( + { owner: "owner", community: "wss://one.example" }, + () => true, + (error) => errors.push(error), + ipc, + client, + ); + + await flushUntil(() => sentProtocolFrames("REQ").length === 1); + const retiredSubId = sentProtocolFrames("REQ")[0][1]; + await deliver(client, ["EOSE", retiredSubId]); + await flushUntil(() => resolveHistory !== undefined); + await deliver(client, [ + "EVENT", + retiredSubId, + { id: "queued-old", kind: 50182, created_at: 1 }, + ]); + tickTo(20); + await deliver(client, ["CLOSED", retiredSubId, "restricted: access revoked"]); + resolveHistory([]); + + await assert.rejects(receiving, /receiver is unavailable/); + await flushMicrotasks(); + assert.equal( + ipcCalls.includes("receive_desktop_lifecycle"), + false, + "queued work from the retired receiver must not execute", + ); + assert.match(errors.at(-1), /subscription closed/); + + client.fetchEvents = async () => []; + const retried = receiveLifecycle( + { owner: "owner", community: "wss://one.example" }, + () => true, + (error) => errors.push(error), + ipc, + client, + ); + await flushUntil(() => sentProtocolFrames("REQ").length === 2); + const freshSubId = sentProtocolFrames("REQ")[1][1]; + assert.notEqual(freshSubId, retiredSubId); + await deliver(client, ["EOSE", freshSubId]); + const close = await retried; + + await deliver(client, [ + "EVENT", + freshSubId, + { id: "new-live", kind: 50182, created_at: 2 }, + ]); + tickTo(40); + await flushUntil(() => ipcCalls.includes("receive_desktop_lifecycle")); + assert.equal( + ipcCalls.filter((command) => command === "receive_desktop_lifecycle") + .length, + 1, + ); + await close(); +}); diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 7c7d96f44c1..117731df914 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -112,7 +112,7 @@ test("channel replay lookback stays coupled to relay and DB source constants", a assert.match( ingest, new RegExp( - `MAX_TIMESTAMP_DRIFT_SECS: i64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`, + `MAX_TIMESTAMP_DRIFT_SECS: u64 = ${RELAY_INGEST_FUTURE_TOLERANCE_SECS}`, ), ); assert.match( diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index ed7e053f259..3d33e925c13 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -24,6 +24,8 @@ export async function startManagedAgent( * long the spawn takes. Local spawns receive it as process env; provider * deploys carry it in the payload's launch.policy_env. */ replayFloorUnix?: number; + /** Only a deliberate Start button may supersede a remote Stop. */ + explicitStart?: boolean; }, ): Promise { const response = await invokeTauri("start_managed_agent", { @@ -31,6 +33,7 @@ export async function startManagedAgent( expectedRelayUrl: options?.expectedRelayUrl ?? null, expectedSignerPubkey: options?.expectedSignerPubkey ?? null, replayFloorUnix: options?.replayFloorUnix ?? null, + explicitStart: options?.explicitStart ?? false, }); return fromRawManagedAgent(response); } @@ -81,8 +84,13 @@ export async function listManagedAgentRuntimes(): Promise< export async function startManagedAgentRuntime( pubkey: string, relayUrl: string, + explicitStart = false, ): Promise { - return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl }); + return invokeTauri("start_managed_agent_runtime", { + pubkey, + relayUrl, + explicitStart, + }); } export async function stopManagedAgentRuntime( diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b988843d60b..e484122bb67 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -335,7 +335,12 @@ export type ManagedAgent = { systemPrompt: string | null; avatarUrl: string | null; model: string | null; - modelSource: "definition" | "global" | "instance_legacy" | null; + modelSource: + | "definition" + | "global" + | "instance_legacy" + | "runtime_configuration" + | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; /** True when the linked persona has been edited since this agent was created. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6ddc1111b03..437916a1315 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -306,6 +306,7 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + desktopLifecycleObservationError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; @@ -11273,6 +11274,13 @@ function sendToMockSocket(args: { return; } + // Desktop inventory/control records are global-only. Native IPC owns their + // encryption and result validation; smoke fixtures supply that boundary. + if ([30180, 30181, 30182, 50180, 50181].includes(event.kind)) { + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + const channelId = getChannelIdFromTags(event.tags); if (!channelId) { sendWsText(socket.handler, [ @@ -13910,6 +13918,11 @@ export function maybeInstallE2eTauriMocks() { ], }; } + case "observe_desktop_placement": { + const error = activeConfig?.mock?.desktopLifecycleObservationError; + if (error) throw new Error(error); + return null; + } case "list_managed_agents": return handleListManagedAgents(activeConfig); case "get_agent_memory": diff --git a/desktop/tests/e2e/desktop-stop.spec.ts b/desktop/tests/e2e/desktop-stop.spec.ts new file mode 100644 index 00000000000..87e0de3c991 --- /dev/null +++ b/desktop/tests/e2e/desktop-stop.spec.ts @@ -0,0 +1,290 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +// These are IPC fixtures, not native execution evidence. The real mounted +// Known Desktops, client, relay publisher and retry control remain in the path. +test("remote Stop distinguishes delivery, uncertainty, and confirmed result", async ({ + page, +}) => { + test.setTimeout(60_000); + const agent = "a7".repeat(32); + const remoteDesktop = "22222222-2222-4222-8222-222222222222"; + const standardRuntime = { id: "standard-runtime", revision: "3" }; + await installMockBridge(page, { + managedAgents: [], + relayAgents: [ + { + pubkey: agent, + name: "Scout", + ownerPubkey: "deadbeef".repeat(8), + status: "unknown", + respondTo: "owner-only", + channelNames: [], + channelIds: [], + }, + ], + }); + await page.goto("/"); + await expect(page.getByTestId("open-agents-view")).toBeVisible(); + await page.evaluate((configuration: { id: string; revision: string }) => { + const w = window as typeof window & { + __STOP_FIXTURE__: { + confirmed: boolean; + prepared: number; + sends: string[]; + lifecyclePrepared: number; + lifecycleSends: string[]; + }; + __TAURI_INTERNALS__: { + invoke: (command: string, payload?: any, options?: any) => Promise; + }; + }; + w.__STOP_FIXTURE__ = { + confirmed: false, + prepared: 0, + sends: [], + lifecyclePrepared: 0, + lifecycleSends: [], + }; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + const now = Math.floor(Date.now() / 1000); + const local = "11111111-1111-4111-8111-111111111111"; + const remote = "22222222-2222-4222-8222-222222222222"; + // The mounted control issues status and catalog actions over the same + // lifecycle IPC before any launch. Identify each signed request by its + // event id so launch accounting stays scoped to the destination Start. + const lifecycleRequests = new Map< + string, + { action: string; desktop: string } + >(); + const eligibleCatalogEntry = { + configuration, + name: "Standard runtime", + host: remote, + runtime: "acp", + model: "claude-opus-4-6", + provider: null, + eligible: true, + }; + const sign = async (kind: number, tags: string[][] = []) => + JSON.parse( + await original("sign_event", { + kind, + tags, + content: "encrypted IPC fixture", + createdAt: now, + }), + ); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + switch (command) { + case "prepare_desktop_profile": + return { event: await sign(30180, [["d", local]]) }; + case "read_desktop_profiles": + return [ + { id: local, name: "Laptop", updated: now }, + { id: remote, name: "Lab Desktop", updated: now }, + ]; + case "prepare_desktop_observation": + return { event: await sign(30181, [["d", local]]) }; + case "read_desktop_observations": + return [ + { id: local, heard: now }, + { id: remote, heard: now - 600 }, + ]; + case "prepare_desktop_capabilities": + return { event: await sign(30182, [["d", local]]) }; + case "read_desktop_capabilities": + return [local, remote].map((id) => ({ + id, + reported: now, + runtimes: [], + })); + case "observe_desktop_placement": + return; + case "read_desktop_placement": + case "receive_desktop_lifecycle": + return null; + case "prepare_desktop_lifecycle": { + const request = await sign(50182, [ + ["p", payload.owner], + ["d", payload.desktop], + ]); + lifecycleRequests.set(request.id, { + action: payload.action, + desktop: payload.desktop, + }); + if (payload.action === "start") + w.__STOP_FIXTURE__.lifecyclePrepared++; + return request; + } + case "read_desktop_lifecycle_results": { + const request = lifecycleRequests.get(payload.request.id); + if (request?.action === "preflight") + return { + outcome: "ready", + observation: { + valid_until: now + 600, + running_configuration: null, + catalog: null, + }, + }; + if (request?.action !== "catalog") + return { outcome: "provisioning_unavailable" }; + return { + outcome: "ready", + observation: { + valid_until: now + 600, + running_configuration: null, + catalog: { + entry: request.desktop === remote ? eligibleCatalogEntry : null, + next: null, + }, + }, + }; + } + case "prepare_desktop_stop": + w.__STOP_FIXTURE__.prepared++; + return sign(50180, [ + ["p", payload.owner], + ["d", payload.desktop], + ]); + case "receive_desktop_stop": + return null; + case "read_desktop_stop_results": + return w.__STOP_FIXTURE__.confirmed ? "stopped" : "unknown"; + case "plugin:websocket|send": { + const wire = JSON.parse(payload.message.data); + if (wire[0] === "EVENT" && wire[1]?.kind === 50180) + w.__STOP_FIXTURE__.sends.push(JSON.stringify(wire[1])); + if ( + wire[0] === "EVENT" && + wire[1]?.kind === 50182 && + lifecycleRequests.get(wire[1].id)?.action === "start" + ) + w.__STOP_FIXTURE__.lifecycleSends.push(JSON.stringify(wire[1])); + break; + } + } + return original(command, payload, options); + }; + }, standardRuntime); + await page.getByTestId("open-agents-view").click(); + const desktops = page.getByRole("region", { name: "Known Desktops" }); + await desktops.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect( + desktops.getByRole("listitem").getByText("Lab Desktop", { exact: true }), + ).toBeVisible(); + await desktops + .getByRole("combobox", { name: "Agent to stop on Lab Desktop" }) + .selectOption(agent); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/01-selected.png", + }); + + await desktops + .getByRole("button", { name: "Stop on Lab Desktop", exact: true }) + .click(); + await expect( + desktops.getByText("Stop requested. Waiting for this Desktop’s result…", { + exact: true, + }), + ).toBeVisible(); + await expect( + desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }), + ).toHaveCount(0); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/02-waiting.png", + }); + + await expect( + desktops.getByText( + "Stop unconfirmed. This Desktop may be unavailable; its agents may still be running.", + { exact: true }, + ), + ).toBeVisible({ timeout: 25_000 }); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/03-unconfirmed.png", + }); + await page.evaluate(() => { + ( + window as typeof window & { __STOP_FIXTURE__: { confirmed: boolean } } + ).__STOP_FIXTURE__.confirmed = true; + }); + await desktops + .getByRole("button", { name: "Retry same Stop", exact: true }) + .click(); + await expect( + desktops.getByText("Stop confirmed by Lab Desktop.", { exact: true }), + ).toBeVisible(); + const result = await page.evaluate( + () => + ( + window as typeof window & { + __STOP_FIXTURE__: { prepared: number; sends: string[] }; + } + ).__STOP_FIXTURE__, + ); + expect(result.prepared).toBe(1); + expect(result.sends).toHaveLength(2); + expect(result.sends[1]).toBe(result.sends[0]); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/04-confirmed.png", + }); + + // The mounted lifecycle controls share host labels with the Stop rows. + // IPC explicitly refuses launch; no native process is created by this fixture. + const controls = desktops.getByRole("region", { + name: "Agent placement controls", + }); + // Operation outcomes are announced in the unnamed status region; its + // labeled sibling reports the observed running configuration. + const outcome = controls + .getByRole("status") + .and(controls.locator("p:not([aria-label])")); + await controls + .getByRole("combobox", { name: "Agent to place" }) + .selectOption(agent); + await expect(outcome).toHaveText( + "Choose a configuration for the next Start. This does not change an existing process.", + ); + await controls + .getByRole("combobox", { name: "Runtime configuration" }) + .selectOption( + `${remoteDesktop}:${standardRuntime.id}:${standardRuntime.revision}`, + ); + await controls + .getByRole("button", { name: "Start on destination", exact: true }) + .click(); + await expect(outcome).toHaveText( + "Destination cannot launch this agent. No new process was started.", + ); + await waitForAnimations(page); + await desktops.screenshot({ + path: "test-results/desktop-stop/05-launch-unavailable.png", + }); + await controls + .getByRole("button", { name: "Retry same request", exact: true }) + .click(); + await expect(outcome).toHaveText( + "Destination cannot launch this agent. No new process was started.", + ); + const lifecycle = await page.evaluate( + () => + ( + window as typeof window & { + __STOP_FIXTURE__: { + lifecyclePrepared: number; + lifecycleSends: string[]; + }; + } + ).__STOP_FIXTURE__, + ); + expect(lifecycle.lifecyclePrepared).toBe(1); + expect(lifecycle.lifecycleSends).toHaveLength(2); + expect(lifecycle.lifecycleSends[1]).toBe(lifecycle.lifecycleSends[0]); +}); diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index d1615dbcc64..5a49175d085 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; type TauriConfig = { app: { @@ -109,8 +110,17 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => page, }) => { await spoofMacPlatform(page); - await installMockBridge(page); + await installMockBridge(page, { + desktopLifecycleObservationError: + "fixture: lifecycle storage unavailable", + }); await page.goto("/"); + // A failed global receiver must remain visible without entering the shell's + // layout flow. Wait through its bounded 1s/2s/4s recovery budget before + // measuring chrome; an intermediate transient failure need not notify. + await expect( + page.getByText(/Desktop lifecycle receiver is unavailable/), + ).toBeVisible({ timeout: 15_000 }); // Lock the native and webview placements together: removing this explicit // Tauri inset or shifting the nav row regresses the macOS chrome alignment. @@ -133,6 +143,10 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => ); await expectNavButtonsFixedSize(page); await expectTopChromeFixedHeight(page); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/desktop-lifecycle/receiver-error-chrome.png", + }); }); test("nav buttons still clear the traffic lights when zoomed out", async ({ diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..d897168c88b 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -63,7 +63,13 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // "Message text" is also the outgoing trigger pane's condition label while + // the inspector exit transition (AnimatePresence mode="wait", 150ms) still + // mounts it. Scope to the step textarea id so the fill waits for the + // intended step pane instead of racing the outgoing trigger input. + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Workflow notification"); } async function createEnabled( @@ -152,7 +158,9 @@ test("inserts template variables with keyboard control and restores the caret", await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - const textarea = dialog.getByLabel("Message text"); + // Step-scoped (see addMessageStep): the label is ambiguous during the + // inspector exit transition. + const textarea = dialog.locator('textarea[id^="wf-step-"][id$="-text"]'); const listbox = page.getByRole("listbox"); await textarea.fill("Hello {{trig"); await expect(listbox).toBeVisible(); @@ -286,6 +294,10 @@ test("round-trips and reopens structured message-text conditions", async ({ await dialog.getByRole("tab", { name: "Form" }).click(); await openTriggerInspector(dialog); + // The inspector column enters through a 240ms opacity/width/x animation; + // settle it before sampling the operator geometry so each boundingBox is + // read from the final layout, not mid-animation positions. + await waitForAnimations(page); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 16844a2f3e2..dd9fbf41e1e 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -96,7 +96,13 @@ async function createWorkflow( await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // "Message text" is also the outgoing trigger pane's condition label while the + // inspector exit transition (AnimatePresence mode="wait", 150ms) still mounts + // it. Scope to the step textarea id so the fill waits for the intended step + // pane instead of racing the outgoing trigger input. + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); await dialog.getByLabel("Name (optional)").fill(options.stepName); @@ -700,7 +706,11 @@ test("captures the built editor at desktop and narrow widths", async ({ await editWorkflowName(dialog, "editor_screenshot"); await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Notify the workflow channel"); + // Step-scoped (see createWorkflow): the label is ambiguous during the + // inspector exit transition. + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Notify the workflow channel"); const inspector = dialog.getByTestId("workflow-node-inspector"); for (const viewport of [ @@ -805,7 +815,11 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("first message"); + // Step-scoped (see createWorkflow): the label is ambiguous during the + // inspector exit transition. + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("first message"); await expect(page).toHaveURL(/pane=step%3Astep_1/); await dialog.getByRole("button", { name: "Add after Step 1" }).click(); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..7c029f9b647 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -221,6 +221,8 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Fail lifecycle history admission to exercise the global receiver warning. */ + desktopLifecycleObservationError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; diff --git a/docs/named-runtime-configurations.md b/docs/named-runtime-configurations.md new file mode 100644 index 00000000000..f9c7ebafc40 --- /dev/null +++ b/docs/named-runtime-configurations.md @@ -0,0 +1,106 @@ +# Named runtime configurations + +A configuration chooses a host, harness, exact model/provider, optional workspace, +and references to already-provisioned local credentials. Several configurations +can target the same host. Identity, persona and community memory do not move or +change when the next-launch selection changes. + +## Scope and persistence + +`ManagedAgentRecord.runtime_configurations` contains owner → community → set. +The existing agent identity remains global; the sets and Desktop IDs do not. +Absent sets migrate to Default. No unscoped prototype configuration is silently +assigned to the currently open community. Management verifies the active scope +and signed ownership, replaces exactly one set under the store lock, and persists +once. Host-local writes preserve other hosts' entries and reject changing them. +New/changed entries receive a new revision; stale whole-record writes fail. + +The effective resolver only consumes the native launch projection, never guesses +a scope from the record's creation relay. Normal Start captures selection for its +actual owner/community pair. Explicit Start takes an exact `{id, revision}`; +null means Default, not “read selection again later”. The immutable `PreparedLaunch` +is not serializable. The management IPC retains names of local credential +references for editing, but no resolved values. Lifecycle catalogs omit workspace, +credential references, environment and identity material entirely. + +## Launch and actual state + +Preparation checks signed owner, exact local key identity, host, runtime, +workspace, readiness and the selected runtime's catalog-declared MCP executable. +Codex/buzz-agent declare `buzz-dev-mcp`; Goose/Claude do not declare a separate +server. Named launches refuse a missing/nonexecutable declared tool and reassert +its resolved command after inherited env. Default retains the existing optional +MCP skip behavior; optional git credential helpers are not configuration tools. + +App preparation also binds effective team instructions and channel/thread session +partitioning: both change the session's instruction/context inputs. Edits to these +apply on the next preparation, rather than changing an in-flight preflight. Other +app state (replay floor, process nonce, logging, shutdown/admission fences) remains +invocation/operational policy, not a snapshot of all app state. + +Ordinary async mesh preflight runs outside the transition lock. Callers fence owner/community after await, then revalidate the plan under +ordinary admission before shared spawn. Pair registration validates before +reaping an existing child and shared spawn checks again. Stop timestamps alone +do not invalidate a plan; changes to its launch inputs do. Scope changes and +configuration revision changes cannot silently substitute another launch. Ordinary +Default selection is fenced too, without imposing strict readiness on its legacy +setup path. App-launch restore captures/preflights the scoped selection and checks +the current record before terminating untracked pair state. + +The running pair and durable runtime receipt stamp the launched reference. +Selection and edits never rewrite that receipt. A different already-running +configuration returns Stop-before-Start rather than satisfying the new request. +The existing Stop/generation/authority fences still apply. Ordinary Default Start +keeps its legacy setup behavior; explicit/catalog Default checks readiness. + +A native spawned child is not proof of a successful ACP session or selected model. +Strict model enforcement belongs to ACP's session boundary; no provider model +fallback is an acceptable success for a named selection. The final native env +sets `BUZZ_ACP_REQUIRED_MODEL` to the exact prepared wire ID after inherited, +harness and mesh writes; non-Claude `BUZZ_ACP_MODEL` is pinned too. Default removes +both strict variables. Claude retains A1 (`ANTHROPIC_MODEL`, no `BUZZ_ACP_MODEL`). +ACP requires matching fresh session/current-model or verified switch evidence +before any prompt, not merely catalog membership or a successful child spawn. + +## Remote Start credentials + +Remote-initiated Start uses the same destination-local credential behavior as +ordinary local Start. The owner must independently provision the agent's matching +identity key on that host. Buzz does not transfer/distribute keys, issue a broker +session, or enroll a host through lifecycle messages. The native shared launcher +may pass that already-local key to its child just as ordinary Start does; lifecycle +requests/results never carry keys, resolved credentials, or local paths. + +Catalog and preflight require the exact owner/community/agent/host configuration +ID and revision, matching local identity, executable harness/tools, workspace and +runtime/provider prerequisites. Launch reads bypass a warm keyring cache and never +migrate a missing key into existence. Missing, unreadable or wrong identity means +ineligible, not an eligible inventory host. Catalog eligibility is revalidated +after async preflight. It is advisory and expires, never launch authority. + +Execution repeats those checks under the shared admission lock before destructive +Restart and after teardown, so a revoked key cannot be recovered from a captured +plan. Lifecycle success/failure, Stop, and cleanup save only metadata into a fresh raw +store; it never persists captured/hydrated credentials or migrates keys. Ownership +and generation reads likewise do not hydrate or migrate keys. The request deadline +also survives through Stop into shared spawn. Failure after Stop is Failed, not +Running or automatic resurrection. A missing key does +not hide a still-running process or block ordinary owner-authorized Stop. Move +preflights before source Stop and rechecks expiry and exact revision afterward; +same-host switches use these same fences. General host inventory is retained; +selection and running configuration are separate. No new provisioning UX is implied. + +## Regression evidence + +`runtime_configurations/tests.rs` binds migration, scope-preserving writes, +foreign refs/hosts, plan revalidation and shared child spawn. The child is a shell +fixture, not a real model. `RuntimeConfigurations.test.mjs` mounts the editor with +mock IPC and checks exact revision, next-launch-only writes, failure and unmount +fences. `runtime_configurations/tests/remote_credentials.rs` exercises the signed +receiver and shared launch with synthetic identities and bounded child processes, +including actual Stop-time credential/executable loss and post-Stop expiry. Run it +on Unix with `cargo test --manifest-path desktop/src-tauri/Cargo.toml +--no-default-features managed_agents::runtime_configurations::tests::remote_credentials +-- --test-threads=1` (13 tests, including the credential-persistence child module; default system-keyring +builds exclude the fixture). +These fixture suites do not constitute real-model or two-Desktop acceptance. diff --git a/migrations/0045_desktop_profile_fts.sql b/migrations/0045_desktop_profile_fts.sql new file mode 100644 index 00000000000..31599368771 --- /dev/null +++ b/migrations/0045_desktop_profile_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop profiles must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30180 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/migrations/0046_desktop_observation_fts.sql b/migrations/0046_desktop_observation_fts.sql new file mode 100644 index 00000000000..ba4776fd040 --- /dev/null +++ b/migrations/0046_desktop_observation_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop observations must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30181 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/migrations/0047_desktop_capabilities_fts.sql b/migrations/0047_desktop_capabilities_fts.sql new file mode 100644 index 00000000000..425d1eee92b --- /dev/null +++ b/migrations/0047_desktop_capabilities_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop capability reports must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30182 THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/migrations/0048_desktop_stop_fts.sql b/migrations/0048_desktop_stop_fts.sql new file mode 100644 index 00000000000..98116efbda9 --- /dev/null +++ b/migrations/0048_desktop_stop_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop Stop requests and results must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50180, 50181) THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/migrations/0049_desktop_lifecycle_fts.sql b/migrations/0049_desktop_lifecycle_fts.sql new file mode 100644 index 00000000000..ad42a795a2e --- /dev/null +++ b/migrations/0049_desktop_lifecycle_fts.sql @@ -0,0 +1,26 @@ +-- Owner-private Desktop lifecycle requests and results must not enter legacy ciphertext search indexes. +-- Like 0033, this rewrites events under ACCESS EXCLUSIVE; schedule accordingly. +DO $$ +DECLARE + existing_expression TEXT; +BEGIN + SELECT pg_get_expr(d.adbin, d.adrelid) + INTO existing_expression + FROM pg_attrdef d + JOIN pg_attribute a + ON a.attrelid = d.adrelid + AND a.attnum = d.adnum + WHERE d.adrelid = 'events'::regclass + AND a.attname = 'search_tsv'; + + IF existing_expression IS NULL THEN + RAISE EXCEPTION 'events.search_tsv generated expression not found'; + END IF; + + ALTER TABLE events DROP COLUMN search_tsv; + EXECUTE format( + 'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind IN (50182, 50183) THEN NULL::tsvector ELSE (%s) END) STORED', + existing_expression + ); + CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); +END $$; diff --git a/schema/schema.sql b/schema/schema.sql index 09508125622..03606b1645f 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -221,7 +221,7 @@ CREATE TABLE events ( -- never matches `@@`. -- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033). search_tsv TSVECTOR GENERATED ALWAYS AS ( - CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector + CASE WHEN kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181, 50182, 50183) THEN NULL::tsvector ELSE to_tsvector('simple', content) END ) STORED,