@@ -28,8 +28,23 @@ fn workspace_owner_hex(state: &AppState) -> Result<String, String> {
2828}
2929
3030/// Build the standard agent JSON payload for provider deploy calls.
31- fn build_deploy_payload ( record : & ManagedAgentRecord ) -> serde_json:: Value {
32- serde_json:: json!( {
31+ ///
32+ /// Fails closed if the agent points at a `persona_id` we can't load — persona
33+ /// env_vars typically hold API credentials, and silently deploying with an
34+ /// empty map would surface as an opaque 401 from the provider.
35+ fn build_deploy_payload (
36+ app : & AppHandle ,
37+ record : & ManagedAgentRecord ,
38+ ) -> Result < serde_json:: Value , String > {
39+ // Merge persona env_vars + agent env_vars for provider deploy. Same
40+ // precedence as local spawn: persona first, agent overrides last. Without
41+ // this, provider-backed agents wouldn't receive credentials saved on the
42+ // persona or the agent itself.
43+ let persona_env =
44+ crate :: managed_agents:: resolve_persona_env ( app, record. persona_id . as_deref ( ) ) ?;
45+ let merged_env = crate :: managed_agents:: merged_user_env ( & persona_env, & record. env_vars ) ;
46+
47+ Ok ( serde_json:: json!( {
3348 "name" : & record. name,
3449 "relay_url" : & record. relay_url,
3550 "private_key_nsec" : & record. private_key_nsec,
@@ -46,7 +61,35 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value {
4661 // to the harness default (`owner-only`) — no protocol break.
4762 "respond_to" : record. respond_to,
4863 "respond_to_allowlist" : & record. respond_to_allowlist,
49- } )
64+ // Merged persona + agent env vars. Providers that don't read this
65+ // field will simply ignore it — no protocol break.
66+ "env_vars" : merged_env,
67+ } ) )
68+ }
69+
70+ /// Persist a deploy-preparation error (currently: persona env resolution
71+ /// failure inside `build_deploy_payload`) into the agent's `last_error`
72+ /// so a refresh shows the cause. Mirrors what `deploy_to_provider` does
73+ /// on its own failures — without this, an agent created with an invalid
74+ /// persona_id would appear as `not_deployed` with no recorded reason.
75+ fn persist_create_deploy_error (
76+ app : & AppHandle ,
77+ state : & AppState ,
78+ pubkey : & str ,
79+ error : & str ,
80+ ) -> Result < ( ) , String > {
81+ let _store_guard = state
82+ . managed_agents_store_lock
83+ . lock ( )
84+ . map_err ( |e| e. to_string ( ) ) ?;
85+ let mut records = load_managed_agents ( app) ?;
86+ let rec = records
87+ . iter_mut ( )
88+ . find ( |r| r. pubkey == pubkey)
89+ . ok_or_else ( || format ! ( "agent {pubkey} not found" ) ) ?;
90+ rec. last_error = Some ( error. to_string ( ) ) ;
91+ rec. updated_at = now_iso ( ) ;
92+ save_managed_agents ( app, & records)
5093}
5194
5295/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
@@ -162,6 +205,7 @@ pub async fn create_managed_agent(
162205 return Err ( "parallelism must be between 1 and 32" . to_string ( ) ) ;
163206 }
164207 }
208+ crate :: managed_agents:: validate_user_env_keys ( & input. env_vars ) ?;
165209
166210 // Validate & normalize the respond-to allowlist BEFORE any side effects.
167211 // The harness has its own validator (sprout-acp/src/config.rs) but we want
@@ -374,6 +418,7 @@ pub async fn create_managed_agent(
374418 // NOT the display_name — ACP's resolve_persona_by_name() matches slugs.
375419 persona_pack_path : pack_metadata. as_ref ( ) . map ( |( path, _) | path. clone ( ) ) ,
376420 persona_name_in_pack : pack_metadata. as_ref ( ) . map ( |( _, name) | name. clone ( ) ) ,
421+ env_vars : input. env_vars . clone ( ) ,
377422 created_at : now_iso ( ) ,
378423 updated_at : now_iso ( ) ,
379424 last_started_at : None ,
@@ -442,11 +487,31 @@ pub async fn create_managed_agent(
442487 . iter ( )
443488 . find ( |r| r. pubkey == pubkey)
444489 . ok_or_else ( || "agent disappeared" . to_string ( ) ) ?;
445- build_deploy_payload ( rec)
490+ build_deploy_payload ( & app , rec)
446491 } ;
447- match deploy_to_provider ( & app, & state, & pubkey, id, config, agent_json, None ) . await {
448- Ok ( ( ) ) => spawn_error,
449- Err ( e) => Some ( e) ,
492+ // The agent was already persisted in Phase 3 — converting a
493+ // persona-resolution failure into `spawn_error` (rather than
494+ // unwinding) keeps the record on disk and surfaces the cause
495+ // in the agent's last_error / UI status. We persist the same
496+ // error string into `last_error` so a refresh after restart
497+ // still shows *why* deploy never happened, matching what
498+ // `deploy_to_provider` does on its own failures.
499+ match agent_json {
500+ Err ( e) => {
501+ if let Err ( persist_err) = persist_create_deploy_error ( & app, & state, & pubkey, & e)
502+ {
503+ eprintln ! (
504+ "sprout-desktop: failed to persist deploy-prep error for {pubkey}: {persist_err}"
505+ ) ;
506+ }
507+ Some ( e)
508+ }
509+ Ok ( json) => {
510+ match deploy_to_provider ( & app, & state, & pubkey, id, config, json, None ) . await {
511+ Ok ( ( ) ) => spawn_error,
512+ Err ( e) => Some ( e) ,
513+ }
514+ }
450515 }
451516 } else {
452517 spawn_error
@@ -521,7 +586,7 @@ pub async fn start_managed_agent(
521586 return build_managed_agent_summary ( & app, record, & runtimes) ;
522587 }
523588
524- let payload = build_deploy_payload ( record) ;
589+ let payload = build_deploy_payload ( & app , record) ? ;
525590 (
526591 record. backend . clone ( ) ,
527592 record. provider_binary_path . clone ( ) ,
0 commit comments