Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ doc/
# Local identity files
identity.key
**/identity.key
.review-logs/
/.review/
3 changes: 3 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ test-results
*.njsproj
*.sln
*.sw?

# Playwright screenshots emitted by the agent-provider spec for PR review.
screenshots/
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default defineConfig({
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/profile.spec.ts",
"**/settings-agent-provider.spec.ts",
"**/tokens.spec.ts",
],
use: {
Expand Down
7 changes: 4 additions & 3 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,22 @@ const overrides = new Map([
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 990], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests
["src-tauri/src/managed_agents/runtime.rs", 1260], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection + sprout-agent mcp_hooks wiring + spawn_agent_child → build_agent_command extraction with sprout-agent provider env injection (calls agent_provider_settings::apply_to_command) + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests covering the provider-settings injection gate AND the gate×apply integration side-effect + build_agent_command×apply variant coverage (Ok-injects / IdentityMismatch-fails-closed / Error-fails-closed)
["src-tauri/src/managed_agents/types.rs", 700], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests
["src-tauri/src/commands/agent_provider_settings/tests.rs", 900], // round-trip + envelope read/write + identity-rotation + validation (oversized prompt, zero timeouts, tiny history bytes) + provider/origin reuse rules + apply_to_command (Ok/None/IdentityMismatch-fails-closed/Error fail-closed + openai-dialect) + stored_to_env_pairs anthropic/openai + R2 review #2 follow-ups (http-loopback hardening, userinfo/query/fragment rejection, control-char/oversized-field validation, owner_pubkey v2 envelope round-trip, inline-args resolver alignment, api_key whitespace trim) + R3 review #3 HIGH 1 (identity-mismatch fails closed) + R7 review #7 follow-ups (validate_stored at spawn time: non-loopback http rejection, control-chars in key/model/base_url, userinfo/query in base_url, unknown provider, oversized prompt, empty-key + loopback local accepted)
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
["src/features/agents/ui/ManagedAgentRow.tsx", 570], // EditAgentDialog integration + provider/local branching + sprout-agent provider-profile label resolution
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 660], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src/shared/api/types.ts", 640], // ... + RespondToMode + respondTo/respondToAllowlist + providerProfileId on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
nostr = { version = "0.37", features = ["nip44"] }
nostr-compat = { package = "nostr", version = "0.36" }
zeroize = "1"
zeroize = { version = "1", features = ["zeroize_derive"] }
reqwest = { version = "0.13", features = ["json", "query", "stream"] }
url = "2"
sprout-core = { path = "../../crates/sprout-core" }
Expand Down
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ pub struct AppState {
pub relay_url_override: Mutex<Option<String>>,
pub managed_agents_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
/// Serializes read-modify-write of the encrypted agent-provider
/// settings envelope (file: `agent-provider-settings.json`).
///
/// **Lock order: this mutex BEFORE `state.keys`.** All three
/// post-boot paths that swap `state.keys` (resolve_persisted_identity,
/// import_identity, apply_workspace) acquire this lock first, so a
/// settings save in flight under the old pubkey never races with an
/// identity rotation. Settings read/write commands also hold this
/// lock across the read-modify-write of the envelope file.
pub agent_provider_settings_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
pub huddle_state: Mutex<HuddleState>,
/// Tauri app handle — stored after setup so huddle commands can emit
Expand Down Expand Up @@ -70,6 +80,7 @@ pub fn build_app_state() -> AppState {
relay_url_override: Mutex::new(None),
managed_agents_store_lock: Mutex::new(()),
channel_templates_store_lock: Mutex::new(()),
agent_provider_settings_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
app_handle: Mutex::new(None),
Expand Down Expand Up @@ -142,6 +153,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
"sprout-desktop: persisted identity pubkey {}",
keys.public_key().to_hex()
);
// Lock order: agent_provider_settings_lock BEFORE state.keys.
// Any in-flight settings read-modify-write will see a
// consistent pubkey across its read+write.
let _settings_guard = state
.agent_provider_settings_lock
.lock()
.map_err(|e| e.to_string())?;
*state.keys.lock().map_err(|e| e.to_string())? = keys;
return Ok(());
}
Expand Down Expand Up @@ -172,6 +190,11 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
"sprout-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
// Lock order: agent_provider_settings_lock BEFORE state.keys (see above).
let _settings_guard = state
.agent_provider_settings_lock
.lock()
.map_err(|e| e.to_string())?;
*state.keys.lock().map_err(|e| e.to_string())? = keys;
Ok(())
}
Expand Down
39 changes: 38 additions & 1 deletion desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::collections::HashSet;
use nostr::Keys;
use tauri::{AppHandle, State};

use crate::commands::agent_provider_settings::{check_provider_profile_id, ProfileIdCheck};
use crate::{
app_state::AppState,
managed_agents::{
Expand Down Expand Up @@ -105,10 +106,40 @@ pub async fn get_agent_models(
/// Name changes are synced to the relay immediately via a kind:0 re-publish.
#[tauri::command]
pub async fn update_managed_agent(
input: UpdateManagedAgentRequest,
mut input: UpdateManagedAgentRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<UpdateManagedAgentResponse, String> {
// Normalize the pin patch: an inner `Some("")` or whitespace-only
// id becomes `Some(None)` (explicit clear), so a stray direct-IPC
// value can't slip past the UI layer and end up as a record field
// that the spawn path will later reject.
if let Some(inner) = input.provider_profile_id.as_mut() {
if let Some(s) = inner.as_deref() {
let trimmed = s.trim();
*inner = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
}

// Defense-in-depth: if the caller is setting a specific provider
// profile id (Some(Some(id))), make sure it resolves now. Clear
// (Some(None)) and untouched (None) are always allowed. See
// `check_provider_profile_id` for the Indeterminate policy.
if let Some(Some(pinned)) = input.provider_profile_id.as_ref() {
if matches!(
check_provider_profile_id(&app, &state, pinned)?,
ProfileIdCheck::Unknown,
) {
return Err(format!(
"provider profile {pinned} no longer exists in Settings → Agent Provider"
));
}
}

// Phase 1: local save (synchronous, under lock)
let (summary, sync_params) = {
let _store_guard = state
Expand Down Expand Up @@ -195,6 +226,12 @@ pub async fn update_managed_agent(
record.respond_to_allowlist = prospective_allowlist;
}

// Tri-state: None = don't touch, Some(None) = clear, Some(Some(id)) = set.
// Mirrors how `model` / `system_prompt` flow above.
if let Some(profile_update) = input.provider_profile_id {
record.provider_profile_id = profile_update;
}

record.updated_at = now_iso();

save_managed_agents(&app, &records)?;
Expand Down
Loading
Loading