diff --git a/src/openhuman/agent_orchestration/pairing.rs b/src/openhuman/agent_orchestration/pairing.rs index bb7d61292..e1dfca372 100644 --- a/src/openhuman/agent_orchestration/pairing.rs +++ b/src/openhuman/agent_orchestration/pairing.rs @@ -251,6 +251,17 @@ async fn contact_status(agent_id: &str) -> Result { Ok(remote_status(&remote).unwrap_or_else(|| "none".to_string())) } +/// Whether the peer is an accepted tiny.place contact. +/// +/// This is intentionally a live relay check rather than a read of the local +/// orchestration pairing store. An accepted contact is already a +/// user-consented recipient for an outbound message, even when it has not been +/// linked as an inbound orchestration session in the current workspace. +pub(crate) async fn is_accepted_contact(agent_id: &str) -> Result { + let agent_id = normalize_agent_id(agent_id)?; + Ok(contact_status(&agent_id).await? == "accepted") +} + fn remote_status(value: &Value) -> Option { value .get("status") diff --git a/src/openhuman/orchestration/tools.rs b/src/openhuman/orchestration/tools.rs index ba9bc47f8..59e9b98bc 100644 --- a/src/openhuman/orchestration/tools.rs +++ b/src/openhuman/orchestration/tools.rs @@ -92,6 +92,14 @@ fn required_str(args: &Value, field: &str) -> Result { } } +fn recipient_may_receive_message( + locally_linked: bool, + has_known_session: bool, + accepted_contact: bool, +) -> bool { + locally_linked || has_known_session || accepted_contact +} + // ── Reasoning-core session-history read tools (Master chat) ────────────────── /// Default / cap on how many messages a `orchestration_read_session` call returns. @@ -473,15 +481,39 @@ impl Tool for SendToAgentTool { let workspace = self.config.workspace_dir.clone(); - // Guardrail: linked peer OR an existing session with them. Never cold-DM - // an arbitrary new address from this un-gated background origin. + // Guardrail: locally linked peer, accepted tiny.place contact, OR an + // existing session with them. Never cold-DM an arbitrary new address + // from this un-gated background origin. The live contact check matters + // after login changes the workspace: the relay contact remains accepted + // while the workspace-local orchestration pairing/session stores start + // empty. let linked = crate::openhuman::agent_orchestration::pairing::linked_agent_ids(&workspace).await; let known_session = store::with_connection(&workspace, |conn| { store::latest_session_for_agent(conn, &recipient) }) .map_err(|e| anyhow::anyhow!("lookup session: {e}"))?; - if !linked.contains(&recipient) && known_session.is_none() { + let accepted_contact = if linked.contains(&recipient) || known_session.is_some() { + false + } else { + match crate::openhuman::agent_orchestration::pairing::is_accepted_contact(&recipient) + .await + { + Ok(accepted) => accepted, + Err(e) => { + log::warn!( + target: "orchestration", + "[orchestration] tool.send_to_agent contact check failed: {e}" + ); + false + } + } + }; + if !recipient_may_receive_message( + linked.contains(&recipient), + known_session.is_some(), + accepted_contact, + ) { log::debug!( target: "orchestration", "[orchestration] tool.send_to_agent refused unlinked recipient", @@ -827,4 +859,12 @@ mod tests { assert!(out.is_error); assert!(out.text().contains("not a linked")); } + + #[test] + fn accepted_contact_can_receive_without_workspace_pairing_or_session() { + assert!(recipient_may_receive_message(false, false, true)); + assert!(recipient_may_receive_message(true, false, false)); + assert!(recipient_may_receive_message(false, true, false)); + assert!(!recipient_may_receive_message(false, false, false)); + } }