diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index ba28ba67c03..5af6a30ba2c 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?; 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..036212bfa58 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, +} + +fn bind_sandbox_scope( + config: &mut serde_json::Value, + scope: Option<&RemoteSessionScope>, + agent_pubkey: &str, + payload: &serde_json::Value, +) -> Result<(), String> { + if config.get("sandbox") != Some(&serde_json::Value::Bool(true)) { + return Ok(()); + } + 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,6 +89,7 @@ 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> { let deploy_lock = { let mut locks = state @@ -66,7 +106,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, cached_binary_path, mut agent_json) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -91,6 +131,7 @@ 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)?; + 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 +303,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/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/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 148155b896f..ba2b4821382 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,6 +559,28 @@ 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) +} + +fn validate_config_value(value: &serde_json::Value, depth: usize) -> 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)?; + } + 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). @@ -566,16 +588,16 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String for (k, v) in obj { let words = split_config_key(k); for f in &forbidden { + // Nested public maps use literal `key` fields (for example Kubernetes + // tolerations). Compound credential names such as apiKey still fail. + if depth > 0 && k == "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)?; } 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..82297d436b6 100644 --- a/desktop/src-tauri/src/managed_agents/backend_tests.rs +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -405,9 +405,17 @@ 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] 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..ede73bba08d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -350,6 +350,18 @@ with a TypeScript lookup table or an id comparison in a component. ## Channel-only runtime controls +Kubernetes provider configuration with `sandbox: true` launches one remote +workspace per published channel thread. The mention send passes the published +event's canonical scope after relay acceptance; a saved `deployed` status never +suppresses that check. Native deploy binds the captured thread and verified +owner after the deployment lock. Missing thread scope refuses startup. +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. +Provider object/array fields accept JSON, with bounded nesting and recursive +secret-key rejection at the native persistence boundary. These are provider +settings, not another harness capability catalog. + Desktop observer controls identify a channel, not a thread session. The harness rejects `cancel_turn` and `switch_model` with `ambiguous_target` when that channel has multiple known session scopes, including retained idle scopes. Do not treat 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 0dfd80d4444..ff4cdb3533a 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs +++ b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs @@ -12,6 +12,20 @@ const schema = { }; describe("coerceConfigValues", () => { + it("parses public scheduling objects and preserves invalid input for validation", () => { + 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), { + pod_options: "invalid", + }); + }); 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 031b0b7277d..facca6736c1 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.tsx +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -20,6 +20,14 @@ export function coerceConfigValues( result[key] = Number.isNaN(num) ? value : num; } else if (schemaType === "boolean") { result[key] = value === "true"; + } else if (schemaType === "object" || schemaType === "array") { + if (value.trim() === "") continue; + try { + result[key] = JSON.parse(value); + } catch { + // Preserve invalid input so the provider can report its schema error. + result[key] = value; + } } else { result[key] = value; } 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..48244a5bf90 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, @@ -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/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/messages/lib/remoteAgentSession.test.mjs b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs new file mode 100644 index 00000000000..388d769e976 --- /dev/null +++ b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + 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("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..699680729ae --- /dev/null +++ b/desktop/src/features/messages/lib/remoteAgentSession.ts @@ -0,0 +1,22 @@ +import type { RelayEvent } from "@/shared/api/types"; +import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; +import { getThreadReference } from "./threading"; + +export type PublishedMessage = Pick; +// biome-ignore lint/suspicious/noConfusingVoidType: legacy send callbacks return Promise without a published event. +export type PublishedMessageResult = PublishedMessage | void; + +export { usesThreadSandbox } from "@/features/agents/lib/threadSandbox"; + +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/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/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/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/docs/remote-agents.md b/docs/remote-agents.md index 0c73b4f5f62..954ab8616d4 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1420,7 +1420,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.