From eadfe010d2ea6399fc40e6e26f2c43b48beaf6fe Mon Sep 17 00:00:00 2001 From: Tee8z Date: Mon, 19 Oct 2026 16:40:16 -0400 Subject: [PATCH] feat(desktop): start thread-bound remote sessions from mentions After a mention is published, pass its channel and canonical thread root to the detached agent start. For a Kubernetes provider with `sandbox: true`, native deploy binds that thread scope and the verified owner after the deployment lock, and refuses to start without a thread. Provider config fields accept bounded, nested JSON objects and arrays. Signed-off-by: Tee8z --- desktop/src-tauri/src/commands/agents.rs | 12 +- .../src/commands/agents/provider_access.rs | 24 ++ .../src/commands/agents/provider_deploy.rs | 73 +++++- .../src-tauri/src/commands/agents_tests.rs | 211 ++++++++++++++++++ .../src/commands/personas/inbound.rs | 6 +- .../personas/inbound/inbound_tests.rs | 65 ++++++ .../src-tauri/src/managed_agents/backend.rs | 69 +++++- .../src/managed_agents/backend_tests.rs | 38 +++- desktop/src-tauri/src/managed_agents/types.rs | 8 + desktop/src/features/agents/AGENTS.md | 30 +++ desktop/src/features/agents/channelAgents.ts | 11 +- desktop/src/features/agents/hooks.ts | 2 + .../lib/instanceInputForDefinition.test.mjs | 14 ++ .../agents/lib/instanceInputForDefinition.ts | 5 +- .../src/features/agents/lib/threadSandbox.ts | 11 + .../agents/ui/ProviderConfigFields.test.mjs | 51 ++++- .../agents/ui/ProviderConfigFields.tsx | 51 +++++ .../agents/ui/whereToRunIntent.test.mjs | 14 ++ .../features/agents/ui/whereToRunIntent.ts | 11 +- .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../features/channels/ui/ChannelPane.types.ts | 7 +- .../features/channels/ui/ChannelScreen.tsx | 2 +- .../channels/useChannelPaneHandlers.ts | 3 +- desktop/src/features/home/ui/HomeView.tsx | 2 + .../src/features/home/ui/InboxDetailPane.tsx | 3 +- .../messages/lib/remoteAgentSession.test.mjs | 85 +++++++ .../messages/lib/remoteAgentSession.ts | 40 ++++ .../messages/lib/videoReviewContext.ts | 3 +- .../messages/ui/MessageComposer.types.ts | 3 +- .../messages/ui/MessageThreadPanel.tsx | 3 +- .../features/messages/ui/MessageTimeline.tsx | 3 +- .../features/messages/ui/NewMessageScreen.tsx | 10 +- .../messages/ui/TimelineMessageList.tsx | 3 +- .../ui/useDetachedAgentStart.test.mjs | 36 +++ .../messages/ui/useDetachedAgentStart.ts | 23 +- .../ui/useEnsureAgentMentionsReady.test.mjs | 73 ++++++ .../ui/useEnsureAgentMentionsReady.ts | 2 + .../ui/useMentionSendFlow.sandbox.test.mjs | 104 +++++++++ .../ui/useMentionSendFlow.test-support.mjs | 2 + .../messages/ui/useMentionSendFlow.ts | 9 +- .../messages/ui/useMentionSendFlow.types.ts | 3 +- .../projects/ui/ProjectAgentChatPanel.tsx | 6 + .../projects/ui/ProjectConversationPanel.tsx | 1 + desktop/src/shared/api/remoteAgentSession.ts | 5 + desktop/src/shared/api/tauriManagedAgents.ts | 3 + desktop/src/shared/ui/VideoPlayer.tsx | 62 +---- desktop/src/shared/ui/VideoPlayer.types.ts | 50 +++++ docs/remote-agents.md | 3 +- 48 files changed, 1162 insertions(+), 96 deletions(-) create mode 100644 desktop/src/features/agents/lib/threadSandbox.ts create mode 100644 desktop/src/features/messages/lib/remoteAgentSession.test.mjs create mode 100644 desktop/src/features/messages/lib/remoteAgentSession.ts create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.sandbox.test.mjs create mode 100644 desktop/src/shared/api/remoteAgentSession.ts create mode 100644 desktop/src/shared/ui/VideoPlayer.types.ts diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index ba28ba67c03..8f194a4750d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -824,7 +824,11 @@ async fn create_managed_agent_in( profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, state, profile_sync_error).await; - let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { + // A thread-bound provider starts only after a mention supplies its scope. + let spawn_error = if input.spawn_after_create + && input.backend != BackendKind::Local + && !input.backend.is_thread_sandbox() + { if let BackendKind::Provider { ref id, ref config } = input.backend { let agent_json = { let _g = state @@ -839,7 +843,7 @@ async fn create_managed_agent_in( build_deploy_payload(&app, state, rec)? }; match deploy_to_provider( - &app, state, &pubkey, id, config, agent_json, None, None, None, None, + &app, state, &pubkey, id, config, agent_json, None, None, None, None, None, ) .await { @@ -888,6 +892,7 @@ pub async fn start_managed_agent( expected_relay_url: Option, expected_signer_pubkey: Option, replay_floor_unix: Option, + session_scope: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -1014,6 +1019,7 @@ pub async fn start_managed_agent( expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), replay_floor_unix, + session_scope.as_ref(), ) .await?; @@ -1240,4 +1246,4 @@ use profile::{profile_needs_sync, resolve_legacy_avatar}; mod admission_tests; #[cfg(test)] #[path = "agents_tests.rs"] -mod tests; +pub(super) mod tests; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 69c2d2f7f83..caa139bc4e1 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -16,6 +16,9 @@ pub(super) fn needs_reconciliation_with_policy( owner_only_access: bool, ) -> bool { (owner_only_access || record.provider_policy_pending) + // Thread Sandboxes enforce their owner/thread inside the harness. + // Configuration applies on the next scoped launch, never on app reopen. + && !record.backend.is_thread_sandbox() && record.backend != BackendKind::Local && record.backend_agent_id.is_some() } @@ -101,6 +104,7 @@ pub(crate) async fn reconcile_on_workspace_apply( None, None, None, + None, ) .await { @@ -148,6 +152,26 @@ mod tests { record } + #[test] + fn thread_sandboxes_do_not_redeploy_during_workspace_apply() { + let mut sandbox = record( + BackendKind::Provider { + id: "kubernetes".into(), + config: serde_json::json!({"sandbox":true}), + }, + Some("thread-workspace"), + ); + sandbox.provider_policy_pending = true; + for owner_only in [false, true] { + assert!( + collect_targets_with(vec![sandbox.clone()], owner_only, |_| { + panic!("an app lifecycle event must not build an unscoped thread deployment") + }) + .is_empty() + ); + } + } + #[test] fn upgrade_collects_existing_provider_and_builds_projected_payload() { let records = vec![ diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index 2dd740c58d1..eb2d307898f 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -13,6 +13,45 @@ use crate::{ use super::build_deploy_payload; +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Published channel and canonical thread that own a remote workspace. +pub struct RemoteSessionScope { + channel_id: uuid::Uuid, + thread_root: String, +} + +/// Replace a thread Sandbox's `sandbox: true` request with the published +/// thread and the rebuilt payload's verified owner. Callers decide eligibility +/// with [`BackendKind::is_thread_sandbox`] on the rebuilt record. +fn bind_sandbox_scope( + config: &mut serde_json::Value, + scope: Option<&RemoteSessionScope>, + agent_pubkey: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + let scope = scope.ok_or("Mention this agent in a channel thread to start its workspace")?; + if scope.thread_root.len() != 64 + || !scope + .thread_root + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err("Remote workspace requires a canonical thread root".into()); + } + let owner = payload + .pointer("/launch/owner_pubkey") + .and_then(|v| v.as_str()) + .ok_or("Remote workspace requires a verified owner")?; + config["sandbox"] = serde_json::json!({ + "channel_id": scope.channel_id.to_string(), "thread_root": scope.thread_root, + }); + config["identity_policy"] = serde_json::json!({ + "agent_pubkey": agent_pubkey, "owner_pubkey": owner, + }); + Ok(()) +} + /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via /// spawn_blocking, and persists the result (backend_agent_id or last_error). /// @@ -50,7 +89,12 @@ pub(crate) async fn deploy_to_provider( expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, + session_scope: Option<&RemoteSessionScope>, ) -> Result<(), String> { + // One lock per agent, not per thread: it serializes the record rebuild, + // provider call, and result write. A thread Sandbox cold start holds it + // through the provider's Pod waits (120s bound each), so starts for the + // same agent's other threads queue behind it. let deploy_lock = { let mut locks = state .provider_deploy_locks @@ -66,7 +110,7 @@ pub(crate) async fn deploy_to_provider( // The payload may have waited behind another deployment. Rebuild it from // the current record so the final provider invocation always carries the // newest saved policy rather than the stale snapshot captured by its caller. - let (provider_id, config, cached_binary_path, mut agent_json) = { + let (provider_id, mut config, thread_sandbox, cached_binary_path, mut agent_json) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -83,6 +127,7 @@ pub(crate) async fn deploy_to_provider( ( provider_id, config, + record.backend.is_thread_sandbox(), record.provider_binary_path.clone(), build_deploy_payload(app, state, record)?, ) @@ -91,6 +136,9 @@ pub(crate) async fn deploy_to_provider( // Assert the caller's captured scope against THIS payload — the exact // value invoked below — not the pre-lock snapshot its caller validated. assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?; + if thread_sandbox { + bind_sandbox_scope(&mut config, session_scope, pubkey, &agent_json)?; + } // The floor is invocation state, not record state, so the post-lock // rebuild cannot restore it — inject it into the payload actually invoked. apply_replay_floor(&mut agent_json, replay_floor_unix); @@ -262,6 +310,29 @@ fn apply_deploy_result( mod tests { use super::*; + #[test] + fn sandbox_scope_is_required_and_bound_to_the_rebuilt_owner() { + let scope = RemoteSessionScope { + channel_id: uuid::Uuid::new_v4(), + thread_root: "a".repeat(64), + }; + let mut config = serde_json::json!({"sandbox":true}); + let payload = serde_json::json!({"launch":{"owner_pubkey":"owner"}}); + assert!(bind_sandbox_scope(&mut config, None, "agent", &payload).is_err()); + assert_eq!(config["sandbox"], true); + bind_sandbox_scope(&mut config, Some(&scope), "agent", &payload).unwrap(); + assert_eq!(config["sandbox"]["thread_root"], scope.thread_root); + assert_eq!(config["identity_policy"]["owner_pubkey"], "owner"); + let mut missing_owner = serde_json::json!({"sandbox":true}); + assert!(bind_sandbox_scope( + &mut missing_owner, + Some(&scope), + "agent", + &serde_json::json!({}) + ) + .is_err()); + } + fn record() -> crate::managed_agents::ManagedAgentRecord { serde_json::from_value(serde_json::json!({ "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f1979d8afc5..190ea79e7c5 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -881,3 +881,214 @@ fn create_with_effort_in_confined_child(home: &std::path::Path) { .starts_with("nsec1")); } } + +// ── Thread Sandbox launch seams ────────────────────────────────────────────── + +/// Re-run one test in a child process with a temp HOME and a PATH holding +/// only `bin`, so provider discovery sees just the fakes a test writes there. +#[cfg(unix)] +pub(in crate::commands) fn run_in_confined_child(test: &str, child_var: &str) { + let home = tempfile::tempdir().unwrap(); + let bin = home.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test, "--nocapture"]) + .env(child_var, home.path()) + .env("HOME", home.path()) + .env("XDG_DATA_HOME", home.path()) + .env("PATH", &bin) + .env_remove("BUZZ_PRIVATE_KEY") + .env_remove("BUZZ_AUTH_TAG") + .env_remove("BUZZ_RELAY_URL") + .env_remove("BUZZ_NETWORK_TRACE") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed"), + "child failed: {stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// A headless app whose agent store, keys, and nest live under `home`. +#[cfg(unix)] +pub(in crate::commands) fn confined_mock_app( + home: &std::path::Path, +) -> tauri::App { + crate::managed_agents::storage::NO_KEYCHAIN_FOR_TEST + .store(true, std::sync::atomic::Ordering::SeqCst); + crate::managed_agents::pin_nest_dir_for_test(home.join("nest")); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + context.config_mut().identifier = home.join("app-data").to_str().unwrap().to_owned(); + let state = crate::app_state::build_app_state(); + // Unroutable relay: nothing in these tests may reach a network. + *state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string()); + tauri::test::mock_builder() + .manage(state) + .build(context) + .expect("mock app builds headless") +} + +/// Install `buzz-backend-` in `bin`: it answers `info`, and records each +/// deploy request in `capture` before acknowledging it. +#[cfg(unix)] +fn install_fake_provider(bin: &std::path::Path, id: &str, capture: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let info = r#"{"ok":true,"name":"fake","version":"1","protocol_version":1,"description":"fake","config_schema":{}}"#; + let path = bin.join(format!("buzz-backend-{id}")); + std::fs::write( + &path, + format!( + "#!/bin/sh\nIFS= read -r request\ncase \"$request\" in\n *'\"op\":\"info\"'*) printf '%s\\n' '{info}' ;;\n *) printf '%s\\n' \"$request\" > '{}'\n printf '%s\\n' '{{\"ok\":true,\"agent_id\":\"remote-1\"}}' ;;\nesac\n", + capture.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +pub(in crate::commands) fn provider_record( + pubkey: &str, + id: &str, + config: serde_json::Value, +) -> ManagedAgentRecord { + let mut record = bare_agent_record(None, Some("gpt-5"), Some("openai")); + record.pubkey = pubkey.to_string(); + record.private_key_nsec = + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".to_string(); + record.backend = crate::managed_agents::BackendKind::Provider { + id: id.to_string(), + config, + }; + record +} + +#[cfg(unix)] +#[test] +fn thread_sandbox_deploy_binds_scope_from_the_rebuilt_record() { + const CHILD: &str = "BUZZ_THREAD_SANDBOX_DEPLOY_TEST_HOME"; + match std::env::var_os(CHILD) { + Some(home) => thread_sandbox_deploy_in_confined_child(std::path::Path::new(&home)), + None => run_in_confined_child( + "commands::agents::tests::thread_sandbox_deploy_binds_scope_from_the_rebuilt_record", + CHILD, + ), + } +} + +#[cfg(unix)] +fn thread_sandbox_deploy_in_confined_child(home: &std::path::Path) { + use tauri::Manager; + let app = confined_mock_app(home); + let state = app.state::(); + let owner = workspace_owner_hex(&state).unwrap(); + let sandbox = || serde_json::json!({"sandbox": true, "namespace": "agents"}); + save_managed_agents( + app.handle(), + &[ + provider_record("thread-agent", "kubernetes", sandbox()), + provider_record("other-agent", "other", sandbox()), + ], + ) + .unwrap(); + let scope: provider_deploy::RemoteSessionScope = serde_json::from_value(serde_json::json!({ + "channelId": "f9ee507e-d03c-4570-baa6-02951c38e1cd", + "threadRoot": "a".repeat(64), + })) + .unwrap(); + // Callers' pre-lock snapshots are stale on purpose: deploy must rebuild. + let stale_payload = serde_json::json!({"launch": {"owner_pubkey": "stale-owner"}}); + let deploy = |pubkey: &str, scope: Option<&provider_deploy::RemoteSessionScope>| { + tauri::async_runtime::block_on(deploy_to_provider( + app.handle(), + &state, + pubkey, + "kubernetes", + &serde_json::json!({}), + stale_payload.clone(), + None, + None, + None, + None, + scope, + )) + }; + + // No provider binary exists yet, so this error proves the refusal happens + // before binary resolution. + let refused = deploy("thread-agent", None).unwrap_err(); + assert!(refused.contains("Mention this agent"), "{refused}"); + + let capture = home.join("deploy.json"); + install_fake_provider(&home.join("bin"), "kubernetes", &capture); + deploy("thread-agent", Some(&scope)).unwrap(); + let request: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap(); + assert_eq!( + request["provider_config"], + serde_json::json!({ + "namespace": "agents", + "sandbox": { + "channel_id": "f9ee507e-d03c-4570-baa6-02951c38e1cd", + "thread_root": "a".repeat(64), + }, + "identity_policy": {"agent_pubkey": "thread-agent", "owner_pubkey": owner}, + }) + ); + assert_eq!(request["agent"]["launch"]["owner_pubkey"], owner); + + // `sandbox: true` on another provider is ordinary configuration. + let other_capture = home.join("other.json"); + install_fake_provider(&home.join("bin"), "other", &other_capture); + deploy("other-agent", None).unwrap(); + let request: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&other_capture).unwrap()).unwrap(); + assert_eq!(request["provider_config"], sandbox()); +} + +#[cfg(unix)] +#[test] +fn create_with_spawn_leaves_thread_sandboxes_for_their_first_mention() { + const CHILD: &str = "BUZZ_THREAD_SANDBOX_CREATE_TEST_HOME"; + match std::env::var_os(CHILD) { + Some(home) => thread_sandbox_create_in_confined_child(std::path::Path::new(&home)), + None => run_in_confined_child( + "commands::agents::tests::create_with_spawn_leaves_thread_sandboxes_for_their_first_mention", + CHILD, + ), + } +} + +#[cfg(unix)] +fn thread_sandbox_create_in_confined_child(home: &std::path::Path) { + use tauri::Manager; + let app = confined_mock_app(home); + let capture = home.join("deploy.json"); + install_fake_provider(&home.join("bin"), "kubernetes", &capture); + let input: CreateManagedAgentRequest = serde_json::from_value(serde_json::json!({ + "name": "Thread worker", "relayUrl": "ws://127.0.0.1:9", "acpCommand": null, + "agentCommand": null, "idleTimeoutSeconds": null, + "maxTurnDurationSeconds": null, "parallelism": null, + "systemPrompt": null, "avatarUrl": null, "model": "gpt-5", + "provider": "openai", "spawnAfterCreate": true, + "backend": {"type": "provider", "id": "kubernetes", "config": {"sandbox": true}}, + })) + .unwrap(); + let state = app.state::(); + let created = tauri::async_runtime::block_on(create_managed_agent_in( + input, + app.handle().clone(), + &state, + )) + .expect("create succeeds"); + + assert_eq!(created.spawn_error, None); + assert!( + !capture.exists(), + "create must not deploy a thread Sandbox before a mention supplies its thread" + ); + let records = load_managed_agents(app.handle()).unwrap(); + assert_eq!(records[0].backend_agent_id, None); +} diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 82892de7e33..0ee9ee170d0 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -136,6 +136,7 @@ pub async fn reconcile_inbound_persona_event( None, None, None, + None, ) .await .map_err(|error| { @@ -376,7 +377,8 @@ fn reconcile_inbound_persona_event_blocking( } } crate::managed_agents::BackendKind::Provider { id, config } - if record.backend_agent_id.is_some() => + if record.backend_agent_id.is_some() + && !record.backend.is_thread_sandbox() => { // Persist the unacknowledged policy transition in the // same write as the narrowed policy. If the process @@ -393,6 +395,8 @@ fn reconcile_inbound_persona_event_blocking( ), }); } + // Thread Sandboxes keep their immutable owner/thread gate and + // take configuration on their next explicit scoped launch. crate::managed_agents::BackendKind::Provider { .. } => {} } } 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 d60a84a650e..55da02529ce 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -991,3 +991,68 @@ fn shared_transport_redaction_preserves_local_override_but_explicit_stock_resets apply_inbound_persona(&mut personas, persona_from_event(&clear).unwrap()); assert_eq!(personas[0].acp_command, None); } + +#[cfg(unix)] +#[test] +fn inbound_access_change_never_redeploys_a_thread_sandbox() { + const CHILD: &str = "BUZZ_INBOUND_THREAD_SANDBOX_TEST_HOME"; + match std::env::var_os(CHILD) { + Some(home) => inbound_thread_sandbox_in_confined_child(std::path::Path::new(&home)), + None => crate::commands::agents::tests::run_in_confined_child( + "commands::personas::inbound::inbound_tests::inbound_access_change_never_redeploys_a_thread_sandbox", + CHILD, + ), + } +} + +#[cfg(unix)] +fn inbound_thread_sandbox_in_confined_child(home: &std::path::Path) { + use crate::commands::agents::tests::{confined_mock_app, provider_record}; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + if crate::managed_agents::owner_only_access_build() { + // Access is pinned to the owner, so no inbound event can change it. + return; + } + let app = confined_mock_app(home); + let deployed = |pubkey: &str, config: serde_json::Value| { + let mut record = provider_record(pubkey, "kubernetes", config); + record.backend_agent_id = Some(format!("{pubkey}-remote")); + record + }; + crate::managed_agents::save_managed_agents( + app.handle(), + &[ + deployed("thread-agent", serde_json::json!({"sandbox": true})), + deployed("plain-agent", serde_json::json!({})), + ], + ) + .unwrap(); + let widen_access = |d_tag: &str| { + let content = serde_json::json!({ + "name": "Remote Agent", "persona_id": null, "system_prompt": null, + "model": "gpt-5", "provider": "openai", "persona_source_version": null, + "parallelism": 1, "respond_to": "anyone", "respond_to_allowlist": [], + }); + let event = EventBuilder::new(Kind::Custom(30177), content.to_string()) + .tags(vec![Tag::parse(["d", d_tag]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + reconcile_inbound_persona_event_blocking( + event.as_json(), + "ws://127.0.0.1:9".to_string(), + app.handle().clone(), + ) + .unwrap() + }; + + // Control: the same access change does refresh an ordinary deployment. + assert!(matches!( + widen_access("plain-agent"), + Some(InboundRuntimeRefresh::Provider { .. }) + )); + assert!(widen_access("thread-agent").is_none()); + let records = crate::managed_agents::load_managed_agents(app.handle()).unwrap(); + let thread = records.iter().find(|r| r.pubkey == "thread-agent").unwrap(); + assert_eq!(thread.respond_to, crate::managed_agents::RespondTo::Anyone); + assert!(!thread.provider_policy_pending); +} diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 148155b896f..2082101cc29 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -547,7 +547,7 @@ pub fn provider_deploy( .ok_or_else(|| "deploy response missing agent_id".to_string()) } -/// Validate provider_config: flat object, scalar values, no secret-like keys. +/// Bound public provider configuration and reject secret-like keys at every depth. pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { let obj = config .as_object() @@ -559,23 +559,78 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String if json_str.len() > 65536 { return Err("provider_config: max 64KB".to_string()); } + validate_config_value(config, 0, ConfigPath::Root) +} + +/// Where a value sits relative to the one public field allowed to be named +/// `key`: `pod_options.tolerations[].key`. +#[derive(Clone, Copy, PartialEq)] +enum ConfigPath { + Root, + PodOptions, + Tolerations, + Toleration, + Other, +} + +impl ConfigPath { + fn field(self, name: &str) -> Self { + match (self, name) { + (Self::Root, "pod_options") => Self::PodOptions, + (Self::PodOptions, "tolerations") => Self::Tolerations, + _ => Self::Other, + } + } + + fn entry(self) -> Self { + if self == Self::Tolerations { + Self::Toleration + } else { + Self::Other + } + } +} + +fn validate_config_value( + value: &serde_json::Value, + depth: usize, + path: ConfigPath, +) -> Result<(), String> { + if depth > 4 { + return Err("provider_config: max nesting depth is 4".into()); + } + if let Some(values) = value.as_array() { + if values.len() > 64 { + return Err("provider_config: max 64 array entries".into()); + } + for entry in values { + validate_config_value(entry, depth + 1, path.entry())?; + } + return Ok(()); + } + let Some(obj) = value.as_object() else { + return Ok(()); + }; + if obj.len() > 20 { + return Err("provider_config: max 20 fields per object".into()); + } // Split on separators AND camelCase boundaries, then check each word. // Catches: api_key, apiKey, access-token, clientSecret, etc. // Allows: keyboard, monkey_wrench (no forbidden word as a segment). let forbidden = ["secret", "password", "token", "key", "credential"]; for (k, v) in obj { let words = split_config_key(k); + // A Kubernetes toleration names its taint with a literal `key`. + let toleration_key = path == ConfigPath::Toleration && k == "key"; for f in &forbidden { + if toleration_key && *f == "key" { + continue; + } if words.iter().any(|w| w == f) { return Err(format!("provider_config: key '{}' looks like a secret", k)); } } - if v.is_object() || v.is_array() { - return Err(format!( - "provider_config: value for '{}' must be a scalar", - k - )); - } + validate_config_value(v, depth + 1, path.field(k))?; } Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs index b8af06e1c20..5dde2279ba3 100644 --- a/desktop/src-tauri/src/managed_agents/backend_tests.rs +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -405,9 +405,41 @@ fn validate_provider_config_rejects_secret_key() { } #[test] -fn validate_provider_config_rejects_nested() { - let cfg = serde_json::json!({"region": {"us": "east"}}); - assert!(validate_provider_config(&cfg).is_err()); +fn validate_provider_config_bounds_nested_public_options() { + let cfg = serde_json::json!({"pod_options": { + "node_selector": {"workload": "agents"}, + "tolerations": [{"key":"workload", "value":"agents", "effect":"NoSchedule"}] + }}); + assert!(validate_provider_config(&cfg).is_ok()); + let secret = serde_json::json!({"pod_options":{"nested":[{"accessToken":"secret"}]}}); + assert!(validate_provider_config(&secret).is_err()); + assert!(validate_provider_config(&serde_json::json!({"options":{"apiKey":"secret"}})).is_err()); + let too_deep = serde_json::json!({"a":{"b":{"c":{"d":{"e":1}}}}}); + assert!(validate_provider_config(&too_deep).is_err()); +} + +#[test] +fn validate_provider_config_allows_literal_key_only_in_tolerations() { + for misplaced in [ + serde_json::json!({"s3": {"key": "AKIA"}}), + serde_json::json!({"pod_options": {"key": "AKIA"}}), + serde_json::json!({"pod_options": {"node_selector": {"key": "AKIA"}}}), + serde_json::json!({"pod_options": {"tolerations": {"key": "AKIA"}}}), + serde_json::json!({"options": {"tolerations": [{"key": "AKIA"}]}}), + serde_json::json!({"pod_options": {"tolerations": [[{"key": "AKIA"}]]}}), + serde_json::json!({"pod_options": {"tolerations": [{"nested": {"key": "AKIA"}}]}}), + ] { + assert!( + validate_provider_config(&misplaced).is_err(), + "accepted a literal key outside pod_options.tolerations[]: {misplaced}" + ); + } + let toleration = serde_json::json!({"pod_options": {"tolerations": [ + {"key": "workload", "operator": "Equal", "value": "agents"} + ]}}); + assert!(validate_provider_config(&toleration).is_ok()); + let compound = serde_json::json!({"pod_options": {"tolerations": [{"apiKey": "x"}]}}); + assert!(validate_provider_config(&compound).is_err()); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index f596b5ddd6b..072552d7309 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -12,6 +12,14 @@ pub enum BackendKind { }, } +impl BackendKind { + /// Whether launches require a published channel thread instead of app lifecycle events. + pub(crate) fn is_thread_sandbox(&self) -> bool { + matches!(self, Self::Provider { id, config } + if id == "kubernetes" && config.get("sandbox") == Some(&serde_json::Value::Bool(true))) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentDefinition { pub id: String, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index a9e6a73dd14..59a30cabd1d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -348,6 +348,36 @@ with a TypeScript lookup table or an id comparison in a component. for resets; owner replay of a redacted head preserves only a nonportable local override. That local path is not synchronized through catalog heads. +## Thread Sandbox launches + +Kubernetes provider configuration with `sandbox: true` launches one remote +workspace per published channel thread. `BackendKind::is_thread_sandbox` (Rust) +and `usesThreadSandbox` (TypeScript) are the only predicates for this; both +require provider id `kubernetes`. The mention send passes the published +event's canonical scope after relay acceptance; a saved `deployed` status never +suppresses that check. Every composer `onSend` returns the relay-accepted event +(`PublishedMessageResult`), so channel, thread, Inbox reply, new DM, Projects, +and video-review sends all supply that scope; `undefined` means nothing was +published. Native deploy decides eligibility from the record rebuilt after the +deployment lock, then binds the captured thread and that payload's verified +owner. Missing thread scope refuses startup before the provider binary is +resolved. The Projects agent chat starts a stopped selected agent before +publishing, so that start is refused for a thread Sandbox; mention it in a +channel thread instead. +Creation and channel attachment save configuration and membership until a mention +supplies that scope. Workspace apply and inbound policy updates do not redeploy +thread Sandboxes; their immutable owner/thread gate remains in the harness. + +The deployment lock is per agent, not per thread. A cold Sandbox start holds it +through the provider's Pod waits (120s bound each), so a start for the same +agent in another thread waits behind it. + +Provider object/array fields accept JSON of the declared shape; the form blocks +submit while a field holds anything else. The native persistence boundary bounds +nesting and rejects secret-like keys at every depth. The only exemption is the +literal `key` of a `pod_options.tolerations[]` entry (a Kubernetes taint name). +These are provider settings, not another harness capability catalog. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 2437e921d56..c17f0e8a58a 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -1,3 +1,4 @@ +import { usesThreadSandbox } from "./lib/threadSandbox"; import { commandsMatch, findReusableGenericAgent, @@ -203,9 +204,13 @@ export async function attachManagedAgentToChannel( // pair — so this ensures the pair the caller is attaching to, never // another community's. const isRemote = input.agent.backend.type === "provider"; - const needsStart = isRemote - ? input.agent.status !== "deployed" - : input.agent.status !== "running" && input.agent.status !== "deployed"; + // A thread Sandbox needs the accepted mention's scope. Without a + // detached queue, attachment only grants membership; the mention starts it. + const needsStart = usesThreadSandbox(input.agent) + ? Boolean(input.detachedStart) + : isRemote + ? input.agent.status !== "deployed" + : input.agent.status !== "running" && input.agent.status !== "deployed"; if (needsStart) { if (input.detachedStart) { input.detachedStart(input.agent); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 1b479a7630e..1fbd6984acc 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -606,6 +606,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl?: string; expectedSignerPubkey?: string; replayFloorUnix?: number; + sessionScope?: import("@/shared/api/remoteAgentSession").RemoteAgentSession; }, ) => typeof input === "string" @@ -614,6 +615,7 @@ export function useStartManagedAgentMutation() { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, replayFloorUnix: input.replayFloorUnix, + sessionScope: input.sessionScope, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index c8cbdca6e4d..07ab148483e 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -7,6 +7,20 @@ import { resolveStartRuntimeForDefinition, } from "./instanceInputForDefinition.ts"; +test("thread Sandboxes defer deployment until a mention supplies scope", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { type: "provider", id: "kubernetes", config: { sandbox: true } }, + ); + assert.equal( + input.spawnAfterCreate, + false, + "create_managed_agent deploys without session_scope; bind_sandbox_scope rejects that invocation", + ); +}); + // ── Phase 1B.3.5: the single definition→instance mapping ──────────────────── // // Every surface that starts an agent from a definition maps through diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 0db8d0a293d..ec8f3b64b36 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -1,3 +1,4 @@ +import { usesThreadSandbox } from "./threadSandbox"; import type { AcpRuntime, AcpRuntimeCatalogEntry, @@ -74,7 +75,7 @@ export function resolveStartRuntimeForDefinition( * - `provider`: remote backend. Mirrors the legacy provider-mode create: * no local ACP/agent/MCP commands are spawned, so none are set; * `startOnAppLaunch` is forced false (remote agents don't auto-start with - * the desktop) and `spawnAfterCreate` true. + * the desktop) and `spawnAfterCreate` true except thread Sandboxes, which wait for a mention. * - `mesh`: relay-mesh compute. The preset patch carries the instance * commands/env the legacy dialog fanned into its field state; env lands in * record env_vars (the instance-override layer — the dial pointer is @@ -130,7 +131,7 @@ export async function buildInstanceInputForDefinition( return { ...base, harnessOverride: false, - spawnAfterCreate: true, + spawnAfterCreate: !usesThreadSandbox({ backend: backendIntent }), startOnAppLaunch: false, backend: { type: "provider", diff --git a/desktop/src/features/agents/lib/threadSandbox.ts b/desktop/src/features/agents/lib/threadSandbox.ts new file mode 100644 index 00000000000..7a5ee7f09d4 --- /dev/null +++ b/desktop/src/features/agents/lib/threadSandbox.ts @@ -0,0 +1,11 @@ +import type { ManagedAgent } from "@/shared/api/types"; + +export function usesThreadSandbox( + agent: Pick, +): boolean { + return ( + agent.backend?.type === "provider" && + agent.backend.id === "kubernetes" && + agent.backend.config.sandbox === true + ); +} diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs index 26f6dc82e90..6bb005572ea 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs +++ b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { coerceConfigValues } from "./ProviderConfigFields.tsx"; +import { + coerceConfigValues, + invalidJsonConfigKeys, +} from "./ProviderConfigFields.tsx"; const schema = { properties: { @@ -29,6 +32,52 @@ describe("coerceConfigValues", () => { ); } }); + it("parses public scheduling objects and never submits invalid JSON", () => { + const nested = { properties: { pod_options: { type: "object" } } }; + assert.deepEqual( + coerceConfigValues( + { pod_options: '{"node_selector":{"workload":"agents"}}' }, + nested, + ), + { pod_options: { node_selector: { workload: "agents" } } }, + ); + assert.deepEqual(coerceConfigValues({ pod_options: "" }, nested), {}); + assert.deepEqual( + coerceConfigValues({ pod_options: "invalid" }, nested), + {}, + ); + }); + it("reports object and array fields whose input is not JSON of that shape", () => { + const nested = { + properties: { + pod_options: { type: "object" }, + tolerations: { type: "array" }, + label: { type: "string" }, + }, + }; + assert.deepEqual( + invalidJsonConfigKeys( + { + pod_options: "{not json", + tolerations: '{"key":"workload"}', + label: "{not json", + }, + nested, + ), + ["pod_options", "tolerations"], + ); + assert.deepEqual( + invalidJsonConfigKeys({ pod_options: "[]", tolerations: "null" }, nested), + ["pod_options", "tolerations"], + ); + assert.deepEqual( + invalidJsonConfigKeys( + { pod_options: " ", tolerations: '[{"key":"workload"}]' }, + nested, + ), + [], + ); + }); it("omits cleared numeric fields without losing explicit zero", () => { assert.deepEqual( coerceConfigValues( diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.tsx b/desktop/src/features/agents/ui/ProviderConfigFields.tsx index 64d8880a795..f7de91c0644 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.tsx +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -23,6 +23,10 @@ export function coerceConfigValues( result[key] = Number.isNaN(num) ? value : num; } else if (schemaType === "boolean") { result[key] = value === "true"; + } else if (schemaType === "object" || schemaType === "array") { + // Submit is blocked while `invalidJsonConfigKeys` reports this field. + const parsed = parseJsonField(value, schemaType); + if (parsed !== undefined) result[key] = parsed; } else { result[key] = value; } @@ -30,6 +34,40 @@ export function coerceConfigValues( return result; } +function parseJsonField(value: string, schemaType: "object" | "array") { + if (value.trim() === "") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return undefined; + } + const isArray = Array.isArray(parsed); + const isObject = parsed !== null && typeof parsed === "object" && !isArray; + return (schemaType === "array" ? isArray : isObject) ? parsed : undefined; +} + +/** Object/array fields whose nonempty input is not JSON of the declared shape. */ +export function invalidJsonConfigKeys( + config: Record, + schema: Record | undefined, +): string[] { + const properties = (schema?.properties ?? {}) as Record< + string, + Record + >; + return Object.entries(config) + .filter(([key, value]) => { + const schemaType = properties[key]?.type; + return ( + (schemaType === "object" || schemaType === "array") && + value.trim() !== "" && + parseJsonField(value, schemaType) === undefined + ); + }) + .map(([key]) => key); +} + export function ProviderConfigFields({ schema, config, @@ -44,6 +82,7 @@ export function ProviderConfigFields({ ((schema as Record)?.required as string[]) ?? [], ); + const invalidKeys = new Set(invalidJsonConfigKeys(config, schema)); const entries = Object.entries(properties) as [ string, Record, @@ -67,6 +106,10 @@ export function ProviderConfigFields({ ) : null} onChange({ ...config, [key]: e.target.value })} placeholder={ @@ -80,6 +123,14 @@ export function ProviderConfigFields({ {typeof prop.description === "string" ? (

{prop.description}

) : null} + {invalidKeys.has(key) ? ( +

+ Enter a valid JSON {prop.type === "array" ? "array" : "object"}. +

+ ) : null} ))} diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 262d55d998d..f4ffc392cdf 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -40,6 +40,20 @@ test("provider selection blocks submit while required config is missing", () => assert.equal(providerConfigComplete(missing), false); }); +test("provider selection blocks submit while an object field holds invalid JSON", () => { + const schema = { + properties: { region: { type: "string" }, pod_options: { type: "object" } }, + required: ["region"], + }; + const draft = (pod_options) => + providerDraft({ + probedProvider: { ok: true, config_schema: schema }, + providerConfig: { region: "us", pod_options }, + }); + assert.equal(canSubmitWhereToRun(draft("{oops")), false); + assert.equal(canSubmitWhereToRun(draft('{"node_selector":{}}')), true); +}); + test("complete provider config allows submit", () => { assert.equal(canSubmitWhereToRun(providerDraft()), true); }); diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index 9aa9248e75d..0b3521ef8cd 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -1,6 +1,9 @@ import type { BackendIntent } from "../lib/instanceInputForDefinition"; import type { BackendProviderProbeResult } from "@/shared/api/types"; -import { coerceConfigValues } from "./ProviderConfigFields"; +import { + coerceConfigValues, + invalidJsonConfigKeys, +} from "./ProviderConfigFields"; /** Draft state of the optional remote-backend selector. */ export type WhereToRunDraft = { @@ -51,8 +54,10 @@ export function providerConfigComplete(draft: WhereToRunDraft): boolean { | Record | undefined; const required: string[] = (schema?.required as string[] | undefined) ?? []; - return required.every( - (key) => (draft.providerConfig[key] ?? "").trim().length > 0, + return ( + required.every( + (key) => (draft.providerConfig[key] ?? "").trim().length > 0, + ) && invalidJsonConfigKeys(draft.providerConfig, schema).length === 0 ); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9b384e1a0f8..4a8a27229ce 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -297,7 +297,7 @@ export const ChannelPane = React.memo(function ChannelPane({ (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); messageTimelineRef.current?.scrollToBottomOnNextUpdate(); - await onSendMessage( + const published = await onSendMessage( content, mentionPubkeys, mediaTags, @@ -316,6 +316,7 @@ export const ChannelPane = React.memo(function ChannelPane({ if (shouldCompleteWelcomeBanner) { completeWelcomeComposerBanner(); } + return published; }, [ activeChannelId, diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 1fe5bf751b8..2e7c351adf4 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import type * as React from "react"; import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; @@ -132,7 +133,7 @@ export type ChannelPaneProps = { threadHeadId: string | null; } | null, forceRest?: boolean, - ) => Promise; + ) => Promise; onSendToChannel: ( message: TimelineMessage, threadRoot: TimelineMessage, @@ -144,7 +145,7 @@ export type ChannelPaneProps = { mentionPubkeys: string[], mediaTags?: string[][], parentEventId?: string, - ) => Promise; + ) => Promise; onSendThreadReply: ( content: string, mentionPubkeys: string[], @@ -154,7 +155,7 @@ export type ChannelPaneProps = { parentEventId: string | null; threadHeadId: string | null; } | null, - ) => Promise; + ) => Promise; onTargetReached?: (messageId: string) => void; onToggleReaction?: ( message: TimelineMessage, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index c6a89078961..5f08937fdd0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -545,7 +545,7 @@ export function ChannelScreen({ mediaTags?: string[][], parentEventId?: string, ) => { - await sendMessageMutateAsync({ + return sendMessageMutateAsync({ content, mediaTags, mentionPubkeys, diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 465a0a6b612..364fcc0e30c 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -309,7 +309,7 @@ export function useChannelPaneHandlers({ } | null, forceRest?: boolean, ) => { - await sendMutateRef.current({ + return sendMutateRef.current({ content, mentionPubkeys, mediaTags, @@ -397,6 +397,7 @@ export function useChannelPaneHandlers({ setThreadScrollTargetId(sentMessage.id); } } + return sentMessage; }, [ setExpandedThreadReplyIds, diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index a803bc14859..e88b13d2d01 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -55,6 +55,7 @@ import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTime import { formatTime } from "@/features/messages/lib/dateFormatters"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { publishedChannelMessage } from "@/features/messages/lib/remoteAgentSession"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; @@ -899,6 +900,7 @@ export function HomeView({ ], })); onRefresh(); + return publishedChannelMessage(channelId, result); } finally { setIsSendingReply(false); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index a29a0e927dc..71b03a258b1 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -41,6 +41,7 @@ import { buildVideoReviewPresentationByMessageId, hasRenderedVideoAttachment, } from "@/features/messages/lib/videoReviewContext"; +import type { PublishedMessage } from "@/features/messages/lib/remoteAgentSession"; import { getThreadReference } from "@/features/messages/lib/threading"; import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; @@ -135,7 +136,7 @@ type InboxDetailPaneProps = { mediaTags?: string[][]; mentionPubkeys: string[]; parentEventId: string | null; - }) => Promise; + }) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, diff --git a/desktop/src/features/messages/lib/remoteAgentSession.test.mjs b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs new file mode 100644 index 00000000000..0f1b2f35abd --- /dev/null +++ b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + publishedChannelMessage, + publishedRemoteSession, + usesThreadSandbox, +} from "./remoteAgentSession.ts"; + +test("published top-level and nested replies select their canonical workspace", () => { + const channelId = "f9ee507e-d03c-4570-baa6-02951c38e1cd"; + const root = "a".repeat(64); + const expected = { channelId, threadRoot: root }; + assert.deepEqual( + publishedRemoteSession({ id: root, tags: [] }, channelId), + expected, + ); + assert.deepEqual( + publishedRemoteSession( + { + id: "b".repeat(64), + tags: [ + ["e", root.toUpperCase(), "", "root"], + ["e", "c".repeat(64), "", "reply"], + ], + }, + channelId, + ), + expected, + ); + assert.equal(publishedRemoteSession(undefined, channelId), undefined); + assert.equal(publishedRemoteSession({ id: root, tags: [] }, null), undefined); + assert.equal( + publishedRemoteSession({ id: "invalid", tags: [] }, channelId), + undefined, + ); +}); + +test("send results without relay tags still resolve their canonical workspace", () => { + const channelId = "f9ee507e-d03c-4570-baa6-02951c38e1cd"; + const root = "a".repeat(64); + const scope = (parentEventId, rootEventId) => + publishedRemoteSession( + publishedChannelMessage(channelId, { + eventId: "b".repeat(64), + parentEventId, + rootEventId, + }), + channelId, + ); + assert.deepEqual(scope(null, null), { + channelId, + threadRoot: "b".repeat(64), + }); + assert.deepEqual(scope(root, null), { channelId, threadRoot: root }); + assert.deepEqual(scope("c".repeat(64), root), { + channelId, + threadRoot: root, + }); +}); + +test("only an explicitly configured Kubernetes Sandbox uses thread launches", () => { + assert.equal( + usesThreadSandbox({ + backend: { + type: "provider", + id: "kubernetes", + config: { sandbox: true }, + }, + }), + true, + ); + assert.equal( + usesThreadSandbox({ + backend: { type: "provider", id: "kubernetes", config: {} }, + }), + false, + ); + assert.equal( + usesThreadSandbox({ + backend: { type: "provider", id: "other", config: { sandbox: true } }, + }), + false, + ); + assert.equal(usesThreadSandbox({ backend: { type: "local" } }), false); +}); diff --git a/desktop/src/features/messages/lib/remoteAgentSession.ts b/desktop/src/features/messages/lib/remoteAgentSession.ts new file mode 100644 index 00000000000..9bcd65f944d --- /dev/null +++ b/desktop/src/features/messages/lib/remoteAgentSession.ts @@ -0,0 +1,40 @@ +import type { RelayEvent, SendChannelMessageResult } from "@/shared/api/types"; +import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; +import { buildThreadReferenceTags, getThreadReference } from "./threading"; + +export type PublishedMessage = Pick; +/** The relay-accepted event, or `undefined` when the sender published nothing. */ +export type PublishedMessageResult = PublishedMessage | undefined; + +export { usesThreadSandbox } from "@/features/agents/lib/threadSandbox"; + +/** Rebuilds the thread references of an event known only by its send result. */ +export function publishedChannelMessage( + channelId: string, + result: Pick< + SendChannelMessageResult, + "eventId" | "parentEventId" | "rootEventId" + >, +): PublishedMessage { + return { + id: result.eventId, + tags: buildThreadReferenceTags( + channelId, + result.parentEventId, + result.rootEventId, + ), + }; +} + +export function publishedRemoteSession( + message: PublishedMessageResult, + channelId: string | null, +): RemoteAgentSession | undefined { + if (!message || !channelId) return undefined; + // Matches the harness: a reply's root (or lone parent), else the message. + const threadRoot = ( + getThreadReference(message.tags).rootId ?? message.id + ).toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(threadRoot)) return undefined; + return { channelId, threadRoot }; +} diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 9e626ab1f83..5c5993b6d1f 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -1,6 +1,7 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import type { TimelineMessage } from "@/features/messages/types"; +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import { isVoiceNoteAttachment } from "@/features/messages/lib/audioAttachment"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; @@ -17,7 +18,7 @@ type SendVideoReviewComment = ( mentionPubkeys: string[], mediaTags?: string[][], parentEventId?: string, -) => Promise; +) => Promise; type ToggleMessageReaction = ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index b98132609ad..7b8496a8a4d 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import type { ReactNode } from "react"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; @@ -97,7 +98,7 @@ export type MessageComposerProps = { } | null, /** Route through the REST publisher even when best-effort enrichment settled empty. */ forceRest?: boolean, - ) => Promise; + ) => Promise; placeholder?: string; profiles?: UserProfileLookup; /** Explicit mention pubkeys from the loaded channel window, newest first. */ diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index bec44224d71..506ce96969a 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import * as React from "react"; import { ArrowDown } from "lucide-react"; @@ -95,7 +96,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadHeadId: string | null; } | null, forceRest?: boolean, - ) => Promise; + ) => Promise; onSendToChannel?: ( message: TimelineMessage, threadRoot: TimelineMessage, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b293be252af..02e1b60e6cd 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import * as React from "react"; import { @@ -105,7 +106,7 @@ type MessageTimelineProps = { mentionPubkeys: string[], mediaTags?: string[][], parentEventId?: string, - ) => Promise; + ) => Promise; unfollowThreadById?: (rootId: string) => void; onToggleReaction?: ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index 5c5c527df7c..7902b494c57 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -5,7 +5,7 @@ import { useOpenDmMutation, useUpsertCachedChannel, } from "@/features/channels/hooks"; -import type { Channel } from "@/shared/api/types"; +import type { Channel, RelayEvent } from "@/shared/api/types"; import { useSendMessageMutation } from "@/features/messages/hooks"; import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; @@ -257,8 +257,9 @@ export function NewMessageScreen() { ); } + let sent: RelayEvent; try { - await sendMessageMutation.mutateAsync({ + sent = await sendMessageMutation.mutateAsync({ targetChannel: directMessage, content, mentionPubkeys, @@ -278,14 +279,15 @@ export function NewMessageScreen() { } if (!isMountedRef.current) { - return; + return sent; } await upsertCachedChannel(directMessage); if (!isMountedRef.current) { - return; + return sent; } await goChannel(directMessage.id, { replace: true }); + return sent; }, [ goChannel, diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d9f24468b2c..d2aa321638b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import * as React from "react"; import { VList } from "virtua"; import type { VListHandle } from "virtua"; @@ -81,7 +82,7 @@ type TimelineMessageListProps = { mentionPubkeys: string[], mediaTags?: string[][], parentEventId?: string, - ) => Promise; + ) => Promise; unfollowThreadById?: (rootId: string) => void; onToggleReaction?: ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index 32408c65f08..98196d14c2d 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -374,6 +374,42 @@ test("a second wake for the same agent is suppressed while the first is in fligh rendered.unmount(); }); +test("Sandbox wakes deduplicate within a thread and remain separate across threads", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + const agent = { + ...AGENT_RECORD, + backend: { type: "provider", id: "kubernetes", config: { sandbox: true } }, + }; + const firstScope = { + channelId: "f9ee507e-d03c-4570-baa6-02951c38e1cd", + threadRoot: "a".repeat(64), + }; + const secondScope = { ...firstScope, threadRoot: "b".repeat(64) }; + await act(async () => { + assert.equal(rendered.result.current.startDetached(agent), false); + assert.equal( + rendered.result.current.startDetached(agent, 100, firstScope), + true, + ); + assert.equal( + rendered.result.current.startDetached(agent, 101, firstScope), + false, + ); + assert.equal( + rendered.result.current.startDetached(agent, 102, secondScope), + true, + ); + await settle(); + }); + assert.equal(startCalls.length, 2); + assert.deepEqual( + startCalls.map((call) => call.sessionScope), + [firstScope, secondScope], + ); + rendered.unmount(); +}); + test("wakes for different agents in one window are not collapsed", async () => { holdStarts = true; const { act, rendered } = await renderDetachedStart(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index 0303e98e193..4794a458682 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -7,6 +7,8 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { getErrorMessage } from "./useMentionSendFlow.helpers"; +import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; +import { usesThreadSandbox } from "@/features/messages/lib/remoteAgentSession"; /** * Detached starts still in flight, keyed by the full tenant scope the wake @@ -125,6 +127,7 @@ function warnAgentMayNotRespond(agentName: string, detail: string): void { export function useDetachedAgentStart(): ( agent: ManagedAgent, replayFloorUnix?: number, + sessionScope?: RemoteAgentSession, ) => boolean { const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; const { activeCommunity } = useCommunities(); @@ -143,7 +146,18 @@ export function useDetachedAgentStart(): ( const expectedSignerPubkey = normalizePubkey(identityQuery.data?.pubkey ?? "") || undefined; return React.useCallback( - (agent: ManagedAgent, replayFloorUnix?: number) => { + ( + agent: ManagedAgent, + replayFloorUnix?: number, + sessionScope?: RemoteAgentSession, + ) => { + if (usesThreadSandbox(agent) && !sessionScope) { + warnAgentMayNotRespond( + agent.name, + "The published thread could not be resolved. Open the channel thread and mention the agent again.", + ); + return false; + } if (!expectedRelayUrl || !expectedSignerPubkey) { // Fail closed: an unscoped start resolves the relay and the signing // identity at execution time, so it can land on whichever tenant is @@ -164,7 +178,11 @@ export function useDetachedAgentStart(): ( // canonicalized at capture, matching `assert_expected_signer`'s // case-insensitive compare, so two casings of one identity cannot split // the key. - const key = `${expectedRelayUrl}\u0000${expectedSignerPubkey}\u0000${normalizePubkey(agent.pubkey)}`; + const scopeKey = + usesThreadSandbox(agent) && sessionScope + ? `${sessionScope.channelId}\u0000${sessionScope.threadRoot}` + : ""; + const key = `${expectedRelayUrl}\u0000${expectedSignerPubkey}\u0000${normalizePubkey(agent.pubkey)}\u0000${scopeKey}`; if (inFlightDetachedStarts.has(key)) { // One wake serves both messages. A local duplicate is a backend no-op // anyway, but a provider redeploy can replace a harness that had just @@ -183,6 +201,7 @@ export function useDetachedAgentStart(): ( expectedRelayUrl, expectedSignerPubkey, replayFloorUnix: replayFloorUnix ?? Math.floor(Date.now() / 1000), + sessionScope, }) .catch((error: unknown) => { // This settles arbitrarily long after the send, and `` diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs index 4646bff0fd4..c169da07d95 100644 --- a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs @@ -110,6 +110,21 @@ test("a stopped member agent is queued for a post-publish wake, never fired", as rendered.unmount(); }); +test("deployed Sandboxes are checked again for each mentioned thread", async () => { + const agent = managedAgent({ + status: "deployed", + backend: { type: "provider", id: "kubernetes", config: { sandbox: true } }, + }); + const rendered = await renderEnsureReady({ + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + memberPubkeys: new Set([MEMBER_AGENT]), + }); + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + assert.equal(result.agentsToWake.length, 1); + assert.equal(result.agentsToWake[0].agent, agent); + rendered.unmount(); +}); + test("only agents that are not up are queued", async () => { const runningLocal = managedAgent({ status: "running" }); const undeployedProvider = managedAgent({ @@ -176,3 +191,61 @@ test("a non-member agent's wake queues through the attach seam instead of firing ); rendered.unmount(); }); + +test("first mention in a new channel queues an already deployed Sandbox", async () => { + const { attachManagedAgentToChannel } = await import( + "@/features/agents/channelAgents.ts" + ); + const agent = managedAgent({ + status: "deployed", + backend: { type: "provider", id: "kubernetes", config: { sandbox: true } }, + }); + const originalInvoke = dom.window.__TAURI_INTERNALS__.invoke; + dom.window.__TAURI_INTERNALS__.invoke = async (command) => { + tauriInvocations.push(command); + assert.equal(command, "add_channel_members"); + return { added: [MEMBER_AGENT], errors: [] }; + }; + const rendered = await renderEnsureReady({ + attachAgentToChannel: ({ channelId, ...input }) => + attachManagedAgentToChannel(channelId, input), + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + memberPubkeys: new Set(), + }); + try { + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + assert.deepEqual(result.errors, []); + assert.deepEqual(tauriInvocations, ["add_channel_members"]); + assert.equal( + result.agentsToWake.length, + 1, + "the new channel needs its own thread Sandbox", + ); + } finally { + rendered.unmount(); + dom.window.__TAURI_INTERNALS__.invoke = originalInvoke; + } +}); + +test("attaching a thread Sandbox without a mention queue grants membership without starting", async () => { + const { attachManagedAgentToChannel } = await import( + "@/features/agents/channelAgents.ts" + ); + const agent = managedAgent({ + backend: { type: "provider", id: "kubernetes", config: { sandbox: true } }, + }); + const originalInvoke = dom.window.__TAURI_INTERNALS__.invoke; + dom.window.__TAURI_INTERNALS__.invoke = async (command) => { + tauriInvocations.push(command); + assert.equal(command, "add_channel_members"); + return { added: [MEMBER_AGENT], errors: [] }; + }; + try { + const result = await attachManagedAgentToChannel(CHANNEL_ID, { agent }); + assert.equal(result.membershipAdded, true); + assert.equal(result.started, false); + assert.deepEqual(tauriInvocations, ["add_channel_members"]); + } finally { + dom.window.__TAURI_INTERNALS__.invoke = originalInvoke; + } +}); diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts index 739d0770465..78cc4c2e309 100644 --- a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { usesThreadSandbox } from "@/features/messages/lib/remoteAgentSession"; import { enqueueAgentWake, getErrorMessage, @@ -122,6 +123,7 @@ export function useEnsureAgentMentionsReady({ if (isCancelled()) break; if (participants.has(pubkey)) { if ( + usesThreadSandbox(readyAgent) || (isProviderBackedAgent(readyAgent) && readyAgent.status !== "deployed") || (!isProviderBackedAgent(readyAgent) && diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.sandbox.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.sandbox.test.mjs new file mode 100644 index 00000000000..f1e00c732f4 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.sandbox.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + setup, + deferred, + KEY, + TEXT, +} from "./useMentionSendFlow.test-support.mjs"; + +const CHANNEL = "11111111-2222-3333-4444-555555555555"; +const ROOT = "c".repeat(64); + +for (const reply of [false, true]) { + test(`sandbox wake waits for publication and uses the ${reply ? "parent" : "new"} thread`, async () => { + const s = await setup(); + s.dismiss(); + s.options.channelId = CHANNEL; + s.options.mentions.memberPubkeys = new Set([KEY]); + s.query.data = [ + { + pubkey: KEY, + name: "RemoteScout", + status: "deployed", + backend: { + type: "provider", + id: "kubernetes", + config: { sandbox: true }, + }, + }, + ]; + const publish = deferred(); + s.options.onSendRef.current = () => publish.promise; + s.rerender(); + let pending; + await s.act(async () => { + pending = s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: CHANNEL, + pendingImeta: [], + trimmed: TEXT, + }); + }); + assert.equal( + s.events("add").length, + 0, + "a pending message cannot launch a workspace", + ); + await s.act(async () => { + publish.resolve({ + id: reply ? "d".repeat(64) : ROOT, + tags: reply + ? [ + ["e", ROOT, "", "root"], + ["e", "e".repeat(64), "", "reply"], + ] + : [], + }); + await pending; + }); + const starts = s.events("add"); + assert.equal( + starts.length, + 1, + "deployed status must still resolve this thread's workspace", + ); + assert.deepEqual(starts[0][1].sessionScope, { + channelId: CHANNEL, + threadRoot: ROOT, + }); + assert.equal(starts[0][1].pubkey, KEY); + }); +} + +for (const outcome of ["rejected", "missing event"]) { + test(`sandbox refuses a launch when publication is ${outcome}`, async () => { + const s = await setup(); + s.dismiss(); + s.options.mentions.memberPubkeys = new Set([KEY]); + s.query.data = [ + { + pubkey: KEY, + name: "RemoteScout", + status: "deployed", + backend: { + type: "provider", + id: "kubernetes", + config: { sandbox: true }, + }, + }, + ]; + s.options.onSendRef.current = async () => { + if (outcome === "rejected") throw new Error("relay refused publication"); + }; + s.rerender(); + await s.act(async () => { + await s.result.current.sendMessageWithMentionFlow({ + capturedChannelId: CHANNEL, + pendingImeta: [], + trimmed: TEXT, + }); + }); + assert.equal(s.events("add").length, 0); + assert.equal(s.events("error").length, 1, "failed launch must be visible"); + }); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs index 893f9fd5230..200d788b540 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -7,6 +7,7 @@ import * as React from "react"; import ts from "typescript"; import * as helpers from "./useMentionSendFlow.helpers.ts"; import * as draftStore from "../lib/useDrafts.ts"; +import * as remoteAgentSession from "../lib/remoteAgentSession.ts"; // Execute the product hooks with real React effects/renders; only external // query/mutation/media dependencies are mocked. Deferred promises isolate the @@ -90,6 +91,7 @@ export async function setup({ lifecycle = false } = {}) { const stubs = { react: React, "@/features/messages/lib/useDrafts": draftStore, + "@/features/messages/lib/remoteAgentSession": remoteAgentSession, sonner: { toast: { error: (error) => calls.push(["error", error]) } }, "@/features/agents/hooks": new Proxy( {}, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index a74645d1221..d9d5305606f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -47,6 +47,7 @@ import { import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; import { AgentMentionAuthorizationError } from "@/features/messages/lib/agentMentionRevalidation"; import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; +import { publishedRemoteSession } from "@/features/messages/lib/remoteAgentSession"; export function useMentionSendFlow({ channelId, @@ -570,7 +571,7 @@ export function useMentionSendFlow({ revalidatedMentionPubkeys, ), ]; - await send( + const published = await send( finalContent, revalidatedMentionPubkeys, finalTagsWithAgentAddress, @@ -585,7 +586,11 @@ export function useMentionSendFlow({ // each wake carries its enqueue-time replay floor so the spawned // harness replays back past this message however late the flush. for (const wake of agentsToWake) { - startAgentDetached(wake.agent, wake.replayFloorUnix); + startAgentDetached( + wake.agent, + wake.replayFloorUnix, + publishedRemoteSession(published, sendChannelId), + ); } if (signal?.aborted || isSendCancelled()) return; const sentMentionPubkeys = new Set( diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts index 3bd9cbcfa93..5b17806065b 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -1,3 +1,4 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; import type * as React from "react"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { ChannelType } from "@/shared/api/types"; @@ -41,7 +42,7 @@ export type UseMentionSendFlowOptions = { threadHeadId: string | null; } | null, forceRest?: boolean, - ) => Promise + ) => Promise >; richText: Pick; setContent: (content: string) => void; diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 77210c82dcf..bc6a84d5fad 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -23,6 +23,7 @@ import { type StoredProjectsAgentConversation, writeStoredProjectsAgentConversation, } from "@/features/projects/lib/projectAgentConversationStorage"; +import { publishedChannelMessage } from "@/features/messages/lib/remoteAgentSession"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -228,6 +229,11 @@ export function ProjectAgentChatPanel({ setStoredConversation(stored); writeStoredProjectsAgentConversation(storageScope, stored); } + return publishedChannelMessage(channel.id, { + eventId: sent.eventId, + parentEventId: conversation?.opener.eventId ?? null, + rootEventId: null, + }); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to reach the agent", diff --git a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx index 11344ea574d..f318028458e 100644 --- a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx @@ -234,6 +234,7 @@ export function ProjectConversationPanel({ setReplyTargetId(rootId); setExpandedReplyIds((current) => new Set(current).add(parentEventId)); setScrollTargetId(sentMessage.id); + return sentMessage; }, [activeChannel, replyTargetId, rootId, sendMessageMutation.mutateAsync], ); diff --git a/desktop/src/shared/api/remoteAgentSession.ts b/desktop/src/shared/api/remoteAgentSession.ts new file mode 100644 index 00000000000..b55584649aa --- /dev/null +++ b/desktop/src/shared/api/remoteAgentSession.ts @@ -0,0 +1,5 @@ +/** A published conversation's immutable remote workspace scope. */ +export type RemoteAgentSession = { + channelId: string; + threadRoot: string; +}; diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index ed7e053f259..d9f6c1525bd 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -7,6 +7,7 @@ import type { ManagedAgent, ManagedAgentRuntimeStatus, } from "@/shared/api/types"; +import type { RemoteAgentSession } from "./remoteAgentSession"; export async function startManagedAgent( pubkey: string, @@ -24,6 +25,7 @@ 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; + sessionScope?: RemoteAgentSession; }, ): 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, + sessionScope: options?.sessionScope ?? null, }); return fromRawManagedAgent(response); } diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index 67036050109..b6ae01f369a 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -15,8 +15,6 @@ import { } from "lucide-react"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { setVideoPlaybackSpeed, @@ -48,52 +46,15 @@ import { saveReviewPlaybackPosition, setVideoReviewOpen, } from "./videoPlayerState"; -type VideoReviewReaction = { - emoji: string; - emojiUrl?: string; - count: number; - reactedByCurrentUser?: boolean; - users: Array<{ - pubkey: string; - displayName: string; - avatarUrl: string | null; - }>; -}; -export type VideoReviewComment = { - id: string; - author: string; - avatarUrl?: string | null; - isAgent?: boolean; - body: string; - createdAt: number; - time: string; - /** Parent event id — when it points at another comment, this is a reply. */ - parentId?: string | null; - reactions?: VideoReviewReaction[]; -}; -export type VideoReviewContext = { - channelId?: string | null; - channelName?: string; - channelType?: ChannelType | null; - comments: VideoReviewComment[]; - disabled?: boolean; - isSending?: boolean; - onSendComment?: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - /** Reply to this comment instead of the video message itself. */ - parentEventId?: string, - ) => Promise; - onToggleCommentReaction?: ( - comment: VideoReviewComment, - emoji: string, - remove: boolean, - ) => Promise; - profiles?: UserProfileLookup; - rootEventId?: string; - title?: string; -}; +import type { + VideoReviewComment, + VideoReviewContext, +} from "./VideoPlayer.types"; +export type { + VideoReviewComment, + VideoReviewContext, +} from "./VideoPlayer.types"; + type VideoPlayerProps = { src: string; poster?: string; @@ -1517,7 +1478,7 @@ function VideoReviewDialog({ setIsPosting(true); setErrorMessage(null); try { - await reviewContext.onSendComment( + const published = await reviewContext.onSendComment( body, options?.mentionPubkeys ?? [], options?.mediaTags, @@ -1526,6 +1487,7 @@ function VideoReviewDialog({ if (replyTo) { setReplyTarget(null); } + return published; } catch (error) { setOptimisticComments((current) => current.filter((comment) => comment.id !== optimisticComment.id), @@ -1548,7 +1510,7 @@ function VideoReviewDialog({ mentionPubkeys: string[], mediaTags?: string[][], ) => { - await postContent(content, { + return postContent(content, { mediaTags, mentionPubkeys, replyTo: replyTargetRef.current?.comment ?? null, diff --git a/desktop/src/shared/ui/VideoPlayer.types.ts b/desktop/src/shared/ui/VideoPlayer.types.ts new file mode 100644 index 00000000000..f9f0284bf46 --- /dev/null +++ b/desktop/src/shared/ui/VideoPlayer.types.ts @@ -0,0 +1,50 @@ +import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; + +export type VideoReviewReaction = { + emoji: string; + emojiUrl?: string; + count: number; + reactedByCurrentUser?: boolean; + users: Array<{ + pubkey: string; + displayName: string; + avatarUrl: string | null; + }>; +}; +export type VideoReviewComment = { + id: string; + author: string; + avatarUrl?: string | null; + isAgent?: boolean; + body: string; + createdAt: number; + time: string; + /** Parent event id — when it points at another comment, this is a reply. */ + parentId?: string | null; + reactions?: VideoReviewReaction[]; +}; +export type VideoReviewContext = { + channelId?: string | null; + channelName?: string; + channelType?: ChannelType | null; + comments: VideoReviewComment[]; + disabled?: boolean; + isSending?: boolean; + onSendComment?: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + /** Reply to this comment instead of the video message itself. */ + parentEventId?: string, + ) => Promise; + onToggleCommentReaction?: ( + comment: VideoReviewComment, + emoji: string, + remove: boolean, + ) => Promise; + profiles?: UserProfileLookup; + rootEventId?: string; + title?: string; +}; diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 5ed7e8fc854..c823402b51d 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1432,7 +1432,8 @@ Launchers must check the `info` schema for required options before credential ha ### Agent Sandbox workspaces -Desktop mention routing is added by the following stack layer. This layer accepts scoped requests from direct provider clients. +Set `sandbox=true` in the desktop provider configuration to allocate a workspace for each channel thread. +The desktop publishes the mention first, then passes its channel and canonical thread root to the provider. Direct provider clients pass `sandbox={"channel_id":"","thread_root":""}` and an approved `identity_policy`. Install the `agents.x-k8s.io/v1beta1` Sandbox CRD and controller before launching.