Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,11 @@ async fn create_managed_agent_in<R: tauri::Runtime>(
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
Expand All @@ -839,7 +843,7 @@ async fn create_managed_agent_in<R: tauri::Runtime>(
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
{
Expand Down Expand Up @@ -888,6 +892,7 @@ pub async fn start_managed_agent(
expected_relay_url: Option<String>,
expected_signer_pubkey: Option<String>,
replay_floor_unix: Option<u64>,
session_scope: Option<provider_deploy::RemoteSessionScope>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ManagedAgentSummary, String> {
Expand Down Expand Up @@ -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?;

Expand Down
24 changes: 24 additions & 0 deletions desktop/src-tauri/src/commands/agents/provider_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -101,6 +104,7 @@ pub(crate) async fn reconcile_on_workspace_apply(
None,
None,
None,
None,
)
.await
{
Expand Down Expand Up @@ -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![
Expand Down
66 changes: 65 additions & 1 deletion desktop/src-tauri/src/commands/agents/provider_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -50,6 +89,7 @@ pub(crate) async fn deploy_to_provider<R: tauri::Runtime>(
expected_relay_url: Option<&str>,
expected_signer_pubkey: Option<&str>,
replay_floor_unix: Option<u64>,
session_scope: Option<&RemoteSessionScope>,
) -> Result<(), String> {
let deploy_lock = {
let mut locks = state
Expand All @@ -66,7 +106,7 @@ pub(crate) async fn deploy_to_provider<R: tauri::Runtime>(
// 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()
Expand All @@ -91,6 +131,7 @@ pub(crate) async fn deploy_to_provider<R: tauri::Runtime>(
// 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);
Expand Down Expand Up @@ -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": "",
Expand Down
6 changes: 5 additions & 1 deletion desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub async fn reconcile_inbound_persona_event(
None,
None,
None,
None,
)
.await
.map_err(|error| {
Expand Down Expand Up @@ -376,7 +377,8 @@ fn reconcile_inbound_persona_event_blocking<R: tauri::Runtime>(
}
}
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
Expand All @@ -393,6 +395,8 @@ fn reconcile_inbound_persona_event_blocking<R: tauri::Runtime>(
),
});
}
// Thread Sandboxes keep their immutable owner/thread gate and
// take configuration on their next explicit scoped launch.
crate::managed_agents::BackendKind::Provider { .. } => {}
}
}
Expand Down
36 changes: 29 additions & 7 deletions desktop/src-tauri/src/managed_agents/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,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()
Expand All @@ -545,23 +545,45 @@ 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).
let forbidden = ["secret", "password", "token", "key", "credential"];
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(())
}
Expand Down
14 changes: 11 additions & 3 deletions desktop/src-tauri/src/managed_agents/backend_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,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]
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usesThreadSandbox } from "./lib/threadSandbox";
import {
commandsMatch,
findReusableGenericAgent,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ export function useStartManagedAgentMutation() {
expectedRelayUrl?: string;
expectedSignerPubkey?: string;
replayFloorUnix?: number;
sessionScope?: import("@/shared/api/remoteAgentSession").RemoteAgentSession;
},
) =>
typeof input === "string"
Expand All @@ -614,6 +615,7 @@ export function useStartManagedAgentMutation() {
expectedRelayUrl: input.expectedRelayUrl,
expectedSignerPubkey: input.expectedSignerPubkey,
replayFloorUnix: input.replayFloorUnix,
sessionScope: input.sessionScope,
}),
onSuccess: (updated) => {
queryClient.setQueryData<ManagedAgent[]>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading