Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default defineConfig({
"**/integration.spec.ts",
"**/profile.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
8 changes: 4 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const overrides = new Map([
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + autofocus on mount/channel switch
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38)
Expand All @@ -50,9 +50,9 @@ 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/types.rs", 700], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs)
["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
["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
Expand Down
36 changes: 31 additions & 5 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub async fn get_agent_models(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentModelsResponse, String> {
let (resolved_acp, agent_command, agent_args, persisted_model) = {
let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
Expand Down Expand Up @@ -53,9 +53,22 @@ pub async fn get_agent_models(
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());

(resolved, resolved_agent, args, record.model.clone())
// Same env layering as runtime spawn: persona env < agent env.
// Model discovery needs the user's credentials. Fail closed on
// persona-resolution errors so a corrupt personas.json doesn't
// produce a model list as if the persona had no credentials.
let persona_env =
crate::managed_agents::resolve_persona_env(&app, record.persona_id.as_deref())?;
let env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);

(resolved, resolved_agent, args, record.model.clone(), env)
}; // store lock released — subprocess runs without holding the lock

// Clone the env map for redaction below — `merged_env` is moved
// into the spawn_blocking closure and we still need the values to
// scrub any user-supplied secrets that the child surfaces in stderr.
let env_for_redaction = merged_env.clone();

// Use spawn_blocking because the desktop Tauri crate doesn't enable
// tokio's `process` feature. std::process::Command is synchronous
// but fine for a short-lived subprocess (~2-5s).
Expand All @@ -74,8 +87,12 @@ pub async fn get_agent_models(
.env(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".into()),
)
.stdout(std::process::Stdio::piped())
);
// User env layering — written LAST so it overrides any Sprout-set env above.
for (k, v) in &merged_env {
cmd.env(k, v);
}
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("failed to spawn sprout-acp models: {e}"))
Expand All @@ -86,8 +103,13 @@ pub async fn get_agent_models(

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// Scrub any user-supplied env values before surfacing stderr to
// the frontend — persona/agent env_vars may carry API keys that
// a failing child process echoed back.
let stderr_redacted =
crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction);
return Err(format!(
"sprout-acp models failed (exit {}): {stderr}",
"sprout-acp models failed (exit {}): {stderr_redacted}",
output.status.code().unwrap_or(-1)
));
}
Expand Down Expand Up @@ -171,6 +193,10 @@ pub async fn update_managed_agent(
if let Some(mcp_command) = input.mcp_command {
record.mcp_command = mcp_command;
}
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
record.env_vars = env_vars;
}

// Inbound author gate: merge patch onto current values, then validate
// the merged state. This lets a single update switch to Allowlist AND
Expand Down
81 changes: 73 additions & 8 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,23 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
}

/// Build the standard agent JSON payload for provider deploy calls.
fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value {
serde_json::json!({
///
/// Fails closed if the agent points at a `persona_id` we can't load — persona
/// env_vars typically hold API credentials, and silently deploying with an
/// empty map would surface as an opaque 401 from the provider.
fn build_deploy_payload(
app: &AppHandle,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
// Merge persona env_vars + agent env_vars for provider deploy. Same
// precedence as local spawn: persona first, agent overrides last. Without
// this, provider-backed agents wouldn't receive credentials saved on the
// persona or the agent itself.
let persona_env =
crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?;
let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);

Ok(serde_json::json!({
"name": &record.name,
"relay_url": &record.relay_url,
"private_key_nsec": &record.private_key_nsec,
Expand All @@ -46,7 +61,35 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value {
// to the harness default (`owner-only`) — no protocol break.
"respond_to": record.respond_to,
"respond_to_allowlist": &record.respond_to_allowlist,
})
// Merged persona + agent env vars. Providers that don't read this
// field will simply ignore it — no protocol break.
"env_vars": merged_env,
}))
}

/// Persist a deploy-preparation error (currently: persona env resolution
/// failure inside `build_deploy_payload`) into the agent's `last_error`
/// so a refresh shows the cause. Mirrors what `deploy_to_provider` does
/// on its own failures — without this, an agent created with an invalid
/// persona_id would appear as `not_deployed` with no recorded reason.
fn persist_create_deploy_error(
app: &AppHandle,
state: &AppState,
pubkey: &str,
error: &str,
) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
let rec = records
.iter_mut()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
rec.last_error = Some(error.to_string());
rec.updated_at = now_iso();
save_managed_agents(app, &records)
}

/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
Expand Down Expand Up @@ -162,6 +205,7 @@ pub async fn create_managed_agent(
return Err("parallelism must be between 1 and 32".to_string());
}
}
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;

// Validate & normalize the respond-to allowlist BEFORE any side effects.
// The harness has its own validator (sprout-acp/src/config.rs) but we want
Expand Down Expand Up @@ -374,6 +418,7 @@ pub async fn create_managed_agent(
// NOT the display_name — ACP's resolve_persona_by_name() matches slugs.
persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()),
persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()),
env_vars: input.env_vars.clone(),
created_at: now_iso(),
updated_at: now_iso(),
last_started_at: None,
Expand Down Expand Up @@ -442,11 +487,31 @@ pub async fn create_managed_agent(
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
build_deploy_payload(rec)
build_deploy_payload(&app, rec)
};
match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await {
Ok(()) => spawn_error,
Err(e) => Some(e),
// The agent was already persisted in Phase 3 — converting a
// persona-resolution failure into `spawn_error` (rather than
// unwinding) keeps the record on disk and surfaces the cause
// in the agent's last_error / UI status. We persist the same
// error string into `last_error` so a refresh after restart
// still shows *why* deploy never happened, matching what
// `deploy_to_provider` does on its own failures.
match agent_json {
Err(e) => {
if let Err(persist_err) = persist_create_deploy_error(&app, &state, &pubkey, &e)
{
eprintln!(
"sprout-desktop: failed to persist deploy-prep error for {pubkey}: {persist_err}"
);
}
Some(e)
}
Ok(json) => {
match deploy_to_provider(&app, &state, &pubkey, id, config, json, None).await {
Ok(()) => spawn_error,
Err(e) => Some(e),
}
}
}
} else {
spawn_error
Expand Down Expand Up @@ -521,7 +586,7 @@ pub async fn start_managed_agent(
return build_managed_agent_summary(&app, record, &runtimes);
}

let payload = build_deploy_payload(record);
let payload = build_deploy_payload(&app, record)?;
(
record.backend.clone(),
record.provider_binary_path.clone(),
Expand Down
15 changes: 15 additions & 0 deletions desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub fn create_persona(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name,
Expand All @@ -78,6 +79,7 @@ pub fn create_persona(
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
};
Expand Down Expand Up @@ -122,6 +124,13 @@ pub fn update_persona(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Some(env_vars) = input.env_vars {
// Caller explicitly sent env_vars — replace entirely (empty = clear).
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
// Absent env_vars means "don't touch" — preserve existing creds when
// the caller only meant to edit a different field.
persona.updated_at = now_iso();

save_personas(&app, &personas)?;
Expand Down Expand Up @@ -318,6 +327,12 @@ pub async fn export_persona_to_json(
state: State<'_, AppState>,
) -> Result<bool, String> {
// Load persona data under lock, then drop lock before dialog.
//
// NOTE: `env_vars` are deliberately NOT included in the exported card.
// Persona cards are designed to be shareable artifacts (uploaded,
// forked, distributed), and bundling API keys / credentials in them
// would be a significant footgun. Users who import a card and need
// credentials must supply them post-import via the persona dialog.
let (display_name, system_prompt, avatar_url, provider, model, name_pool) = {
let _store_guard = state
.managed_agents_store_lock
Expand Down
Loading
Loading