Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default defineConfig({
"**/cloud-provenance.spec.ts",
"**/mention-recipients.spec.ts",
"**/remote-owned-mentions.spec.ts",
"**/client-only-agents.spec.ts",
"**/forum-agent-invitation.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ pub struct AppState {
/// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots.
/// Never perform network I/O while holding this lock.
pub managed_agent_runtime_transition: Mutex<()>,
/// Device execution policy is fixed until restart; never synchronized.
pub(crate) agent_device_policy: std::sync::OnceLock<
Result<crate::managed_agents::device_policy::model::DeviceAgentPolicy, String>,
>,
/// Serializes local agent name checks through durable creation or rename.
pub(crate) agent_name_transition: Arc<tokio::sync::Mutex<()>>,
pub managed_agents_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
Expand Down Expand Up @@ -220,6 +226,8 @@ pub fn build_app_state() -> AppState {
shutdown_started: AtomicBool::new(false),
managed_agent_runtime_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
agent_device_policy: std::sync::OnceLock::new(),
agent_name_transition: Arc::new(tokio::sync::Mutex::new(())),
managed_agents_store_lock: Mutex::new(()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,22 @@ async fn list_relay_agents_for_selection(
}

#[tauri::command]
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
list_relay_agents_for_state(&state).await
pub async fn list_relay_agents(
state: State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<Vec<RelayAgentInfo>, String> {
let policy = crate::managed_agents::device_policy::active(&app)?;
let relay_url = crate::relay::relay_api_base_url_with_override(&state);
let mut agents = list_relay_agents_for_state(&state).await?;
agents.retain(|agent| {
policy.allows_identity(
&relay_url,
agent.owner_pubkey.as_deref(),
&agent.name,
&agent.pubkey,
)
});
Ok(agents)
}

/// Revalidate only the selected relay agents in the target channel.
Expand Down
30 changes: 30 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ pub async fn update_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<UpdateManagedAgentResponse, String> {
crate::managed_agents::device_policy::require_hosting(&app)?;
let _name_guard = state.agent_name_transition.clone().lock_owned().await;
{
let records = load_managed_agents(&app)?;
let record = records
.iter()
.find(|record| record.pubkey == input.pubkey)
.ok_or("Agent not found")?;
crate::managed_agents::device_policy::require_record(&app, record)?;
if let Some(name) = input.name.as_deref() {
crate::managed_agents::device_policy::active(&app)?
.check_name_update(
&record.name,
name,
Some(&record.pubkey),
record.persona_id.as_deref(),
|| {
crate::managed_agents::device_policy::unique_names::preflight(
&app,
&state,
name,
record.persona_id.as_deref(),
Some(&record.pubkey),
)
},
)
.await?;
}
}
// Phase 1: local save (synchronous, under lock)
let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = {
let _store_guard = state
Expand All @@ -163,6 +192,7 @@ pub async fn update_managed_agent(
}

let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;
let previous_record = record.clone();

let mut name_changed = false;
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/agent_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub async fn set_managed_agent_start_on_app_launch(

{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;
record.start_on_app_launch = start_on_app_launch;
record.updated_at = now_iso();
}
Expand Down Expand Up @@ -97,6 +98,7 @@ pub async fn set_managed_agent_auto_restart(

{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;
record.auto_restart_on_config_change = auto_restart_on_config_change;
record.updated_at = now_iso();
}
Expand Down
28 changes: 27 additions & 1 deletion desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?
};
crate::managed_agents::device_policy::require_record(app, &record_snapshot)?;
if record_snapshot.backend != BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
Expand Down Expand Up @@ -182,6 +183,7 @@ pub(super) async fn start_local_agent_with_preflight(
.ok_or_else(|| format!("agent {pubkey} not found"))?
};

crate::managed_agents::device_policy::require_record(app, &record_snapshot)?;
if record_snapshot.backend != BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
Expand Down Expand Up @@ -291,6 +293,9 @@ pub(crate) use provider_deploy::deploy_to_provider;
// and `std::sync::MutexGuard` is not `Send`.
#[tauri::command]
pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSummary>, String> {
if crate::managed_agents::device_policy::is_client_only(&app) {
return Ok(Vec::new());
}
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
Expand Down Expand Up @@ -322,6 +327,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSumma
crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
records
.iter()
.filter(|record| crate::managed_agents::device_policy::can_host_record(&app, record))
.map(|record| {
build_managed_agent_summary(
&app,
Expand All @@ -344,13 +350,23 @@ pub async fn create_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<CreateManagedAgentResponse, String> {
crate::managed_agents::device_policy::require_hosting(&app)?;
let name = input.name.trim().to_string();
let requested_persona_id = input
.persona_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let _name_guard = state.agent_name_transition.clone().lock_owned().await;
crate::managed_agents::device_policy::unique_names::preflight(
&app,
&state,
&name,
requested_persona_id.as_deref(),
None,
)
.await?;
validate_create_definition(&name, requested_persona_id.as_deref(), &input)?;
if let Some(parallelism) = input.parallelism {
if !(1..=32).contains(&parallelism) {
Expand Down Expand Up @@ -400,7 +416,11 @@ pub async fn create_managed_agent(
let personas = load_personas(&app)?;
ensure_persona_is_active(&personas, persona_id)?;
}
let keys = Keys::generate();
let keys = crate::managed_agents::device_policy::generate_agent_keys(
&app,
&name,
requested_persona_id.as_deref(),
)?;
let pubkey = keys.public_key().to_hex();
if records.iter().any(|record| record.pubkey == pubkey) {
return Err(format!("agent {pubkey} already exists"));
Expand Down Expand Up @@ -727,6 +747,7 @@ pub async fn create_managed_agent(
.lock()
.map_err(|e| e.to_string())?;
let record = find_managed_agent_mut(&mut records, &pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;
record.updated_at = now_iso();
record.last_error = Some(error.clone());
save_managed_agents(&app, &records)?;
Expand Down Expand Up @@ -827,6 +848,7 @@ pub async fn start_managed_agent(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ManagedAgentSummary, String> {
crate::managed_agents::device_policy::require_hosting(&app)?;
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
// Read outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
Expand Down Expand Up @@ -887,6 +909,7 @@ pub async fn start_managed_agent(
}

let record = find_managed_agent_mut(&mut records, &pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;

// Resolve the effective harness for the avatar-fallback derivation in
// profile reconcile (the create-time snapshot may be empty or stale for
Expand Down Expand Up @@ -1032,6 +1055,7 @@ pub async fn stop_managed_agent(

{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
crate::managed_agents::device_policy::require_record(&app, record)?;
// Remote agents are stopped via !shutdown @mention from the frontend,
// not via this backend command. Reject the call.
if record.backend != BackendKind::Local {
Expand Down Expand Up @@ -1076,6 +1100,7 @@ pub async fn delete_managed_agent(
force_remote_delete: Option<bool>,
app: AppHandle,
) -> Result<(), String> {
crate::managed_agents::device_policy::require_hosting(&app)?;
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
Expand Down Expand Up @@ -1113,6 +1138,7 @@ pub async fn delete_managed_agent(
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
crate::managed_agents::device_policy::require_record(&app, record)?;
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
Expand Down
14 changes: 11 additions & 3 deletions desktop/src-tauri/src/commands/agents/provider_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,23 @@ pub(crate) async fn reconcile_on_workspace_apply(
app: &AppHandle,
state: &AppState,
) -> Result<(), String> {
if crate::managed_agents::device_policy::is_client_only(app) {
return Ok(());
}
let owner_only_access = crate::managed_agents::owner_only_access_build();
let targets = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
collect_targets_with(load_managed_agents(app)?, owner_only_access, |record| {
super::build_deploy_payload(app, state, record)
})
collect_targets_with(
load_managed_agents(app)?
.into_iter()
.filter(|record| crate::managed_agents::device_policy::can_host_record(app, record))
.collect(),
owner_only_access,
|record| super::build_deploy_payload(app, state, record),
)
};

for target in targets {
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents/provider_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub(crate) async fn deploy_to_provider(
expected_signer_pubkey: Option<&str>,
replay_floor_unix: Option<u64>,
) -> Result<(), String> {
crate::managed_agents::device_policy::require_hosting(app)?;
let deploy_lock = {
let mut locks = state
.provider_deploy_locks
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub(crate) fn build_deploy_payload<R: tauri::Runtime>(
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
crate::managed_agents::device_policy::require_record(app, record)?;
if let Some(err) = crate::managed_agents::spawn_key_refusal(record) {
return Err(err);
}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/commands/agents_pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ pub(crate) fn retain_managed_agent_pending(
let result = (|| -> Result<(), String> {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
let conn = open_retention_db(&scope.db_path)?;
let policy = crate::managed_agents::device_policy::active(app)?;
if policy.unique_names {
crate::managed_agents::device_policy::require_record(app, record)?;
crate::managed_agents::device_policy::sync::register(&conn, &record.pubkey)?;
Comment thread
lechnertech-mike marked this conversation as resolved.
Outdated
}
// Shared engine with the boot-time reconcile: projection content diff
// (no republish for runtime-only churn) + monotonic created_at bump
// past the retained head (NIP-AP step 3).
Expand Down
48 changes: 48 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
use super::*;
use crate::managed_agents::AgentDefinition;

#[test]
fn device_policy_refuses_native_deploy_payload_before_accessing_secrets() {
use crate::managed_agents::device_policy::model::DeviceAgentPolicy;
use tauri::Manager;
let state = crate::app_state::build_app_state();
state
.agent_device_policy
.set(Ok(DeviceAgentPolicy {
client_only: true,
..Default::default()
}))
.unwrap();
let app = tauri::test::mock_builder()
.manage(state)
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.unwrap();
let error = build_deploy_payload(
app.handle(),
&app.state(),
&bare_agent_record(None, None, None),
)
.unwrap_err();
assert!(error.contains("client-only"));
}

#[test]
fn unique_name_policy_refuses_deploy_of_renamed_remote_key() {
use crate::managed_agents::device_policy::model::DeviceAgentPolicy;
use tauri::Manager;
let state = crate::app_state::build_app_state();
let policy: DeviceAgentPolicy = serde_json::from_str(r#"{
"client_only":false,"unique_names":true,"preferred_agents":[{
"relay_url":"https://relay.example","owner_pubkey":"owner","name":"Scout","pubkey":"agent"}]}"#).unwrap();
state.agent_device_policy.set(Ok(policy)).unwrap();
let app = tauri::test::mock_builder()
.manage(state)
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.unwrap();
let record = bare_agent_record(None, None, None);
assert!(build_deploy_payload(app.handle(), &app.state(), &record)
.unwrap_err()
.contains("another device"));
assert!(!crate::managed_agents::device_policy::can_host_record(
app.handle(),
&record
));
}

fn bare_agent_record(
persona_id: Option<&str>,
model: Option<&str>,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/identity_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ pub async fn archive_identity(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<SubmitEventResponse, String> {
crate::managed_agents::device_policy::require_identity_archive(&app, &req.target_pubkey)?;
archive_identity_core(&req, &state, &app).await
}

Expand All @@ -281,6 +282,7 @@ pub async fn unarchive_identity(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<SubmitEventResponse, String> {
crate::managed_agents::device_policy::require_identity_archive(&app, &req.target_pubkey)?;
unarchive_identity_core(&req, &state, &app).await
}

Expand Down
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/personas/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@ pub async fn create_persona(
input: CreatePersonaRequest,
app: AppHandle,
) -> Result<AgentDefinition, String> {
crate::managed_agents::device_policy::require_hosting(&app)?;
use tauri::Manager;
let state = app.state::<AppState>();
let _name_guard = state.agent_name_transition.clone().lock_owned().await;
crate::managed_agents::device_policy::unique_names::preflight(
&app,
&state,
&input.display_name,
None,
None,
)
.await?;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ fn reconcile_inbound_persona_event_blocking<R: tauri::Runtime>(
"managed-agent content was not parsed before retention".to_string()
})?;
let access_changed = apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent);
if access_changed {
if access_changed && !crate::managed_agents::device_policy::pauses_sync(&app) {
let record = agents
.iter_mut()
.find(|record| record.pubkey == d_tag)
Expand Down
Loading