Skip to main content

agentmux_srv/server/app_api/
agent_open.rs

1use super::*;
2
3/// Per-agent-definition async lock serializing `agent.open`'s "check for a
4/// live block / seed resume session / create block / register controller"
5/// sequence.
6///
7/// Without this, two concurrent `agent.open` calls for the same agent (a
8/// double-invocation, or two tabs opened for the same agent close together)
9/// can both observe "not live yet" before either controller actually
10/// registers — `CreateBlock` dispatch, the layout-action enqueue, and
11/// `write_agent_config_files` all await in between — and both seed the same
12/// `resume_session_id`, spawning two controllers that `--resume` the
13/// identical provider session concurrently. That's the TOCTOU reagent
14/// flagged on PR #2059's first concurrency-guard attempt (the earlier
15/// single-point-in-time `agent_live_elsewhere` check closed the
16/// already-live case but not the still-racing-to-become-live case).
17///
18/// Scope note: this only serializes calls handled by THIS process — like
19/// the in-memory `CONTROLLER_REGISTRY` it guards, it can't see a genuinely
20/// different AgentMux instance/channel racing the same registry entry.
21/// Same boundary the live-elsewhere check already accepted.
22static AGENT_OPEN_LOCKS: std::sync::LazyLock<
23    std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
24> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
25
26fn agent_open_lock(agent_id: &str) -> Arc<tokio::sync::Mutex<()>> {
27    let mut locks = AGENT_OPEN_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
28    locks
29        .entry(agent_id.to_string())
30        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
31        .clone()
32}
33
34/// How long `agent.open` waits for a missing pinned CLI to install before telling
35/// the caller to retry. The install is not cancelled when this elapses.
36const AGENT_OPEN_INSTALL_WAIT_SECS: u64 = 150;
37
38/// Resolve the `(env_var, value)` pair to inject for this agent's model
39/// vendor override, if any — redirects the harness at a non-default
40/// backend (e.g. `ANTHROPIC_BASE_URL` for a `claude`-provider agent).
41/// Pure — no I/O — so it's directly unit-testable without the full
42/// async spawn-time harness this is called from.
43///
44/// `None` when there's nothing to override (empty `model_vendor_base_url`)
45/// OR the provider doesn't declare `base_url_env_var` — the latter should
46/// already be impossible by the time an agent reaches spawn (rejected at
47/// `agent.define`), but this stays defensive rather than trusting that
48/// write-time validation is the only thing that can ever set this field.
49/// Resolve the CLI argv for spawning `provider`'s process under `controller_type`
50/// (the string form: `"persistent"` / `"subprocess"` / `"acp"` / `"app-server"`).
51/// Pure — no I/O — so it's directly unit-testable without the full async
52/// spawn-time harness this is called from.
53///
54/// ReAgent P1 on PR #3215: this used to only branch on `"persistent"`, so an
55/// `"app-server"` controller silently fell back to `provider.launch_args` —
56/// Codex's one-shot `exec --json` args — instead of `provider.app_server`'s
57/// `--listen stdio://` args. The spawned child would never complete the App
58/// Server handshake once the provider registry flips a provider's controller
59/// type to `AppServer`.
60fn resolve_cli_args(provider: &providers::ProviderConfig, controller_type: &str) -> Vec<String> {
61    let args: &[&str] = match controller_type {
62        "persistent" => provider.persistent_launch_args.unwrap_or(provider.launch_args),
63        "app-server" => provider
64            .app_server
65            .as_ref()
66            .map(|app_server| app_server.launch_args)
67            .unwrap_or(provider.launch_args),
68        _ => provider.launch_args,
69    };
70    args.iter().map(|s| s.to_string()).collect()
71}
72
73fn resolve_vendor_env_override(
74    provider: &providers::ProviderConfig,
75    agent: &AgentDefinition,
76) -> Option<(&'static str, String)> {
77    if agent.model_vendor_base_url.is_empty() {
78        return None;
79    }
80    provider
81        .base_url_env_var
82        .map(|var| (var, agent.model_vendor_base_url.clone()))
83}
84
85// resolve_effective_provider_id (and its store-lookup helper) moved to
86// Store::resolve_effective_provider_id in backend/storage/agents/definition.rs
87// (2026-08-15) — the identical resolution logic was independently
88// duplicated in identity/resolver/inject.rs's layer-3 credential gate,
89// found reading agent.provider directly (the same mstore-vs-id_store
90// class of bug this file's own version was fixed for in round 3 of PR
91// #2587's review). Consolidated so both call sites share one
92// implementation and can't drift on it separately again — see that
93// method's own doc comment for the full history and the tests that
94// moved with it.
95
96#[cfg(test)]
97mod resolve_vendor_env_override_tests {
98    use super::*;
99
100    fn base_agent(model_vendor_base_url: &str) -> AgentDefinition {
101        AgentDefinition {
102            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
103            id: "a1".to_string(),
104            slug: "a1".to_string(),
105            name: "T".to_string(),
106            icon: String::new(),
107            provider: "claude".to_string(),
108            description: String::new(),
109            working_directory: String::new(),
110            shell: String::new(),
111            provider_flags: String::new(),
112            auto_start: 0,
113            restart_on_crash: 0,
114            idle_timeout_minutes: 0,
115            created_at: 0,
116            agent_type: "host".to_string(),
117            environment: String::new(),
118            agent_bus_id: String::new(),
119            is_seeded: 0,
120            accounts: String::new(),
121            parent_id: String::new(),
122            branch_label: String::new(),
123            updated_at: 0,
124            user_hidden: 0,
125            container_image: String::new(),
126            container_volumes: "[]".to_string(),
127            container_name: String::new(),
128            use_ambient_login: 0,
129            auto_continue_enabled: 0,
130            model_vendor_base_url: model_vendor_base_url.to_string(),
131            memory_id: String::new(),
132        }
133    }
134
135    #[test]
136    fn returns_none_when_override_is_unset() {
137        let provider = providers::get_provider("claude").unwrap();
138        let agent = base_agent("");
139        assert!(resolve_vendor_env_override(provider, &agent).is_none());
140    }
141
142    #[test]
143    fn returns_the_env_var_and_value_for_a_supporting_provider() {
144        let provider = providers::get_provider("claude").unwrap();
145        let agent = base_agent("https://my-proxy.example.com");
146        assert_eq!(
147            resolve_vendor_env_override(provider, &agent),
148            Some(("ANTHROPIC_BASE_URL", "https://my-proxy.example.com".to_string()))
149        );
150    }
151
152    #[test]
153    fn returns_none_for_a_non_supporting_provider_even_if_the_field_is_set() {
154        // Defensive: shouldn't be reachable in practice (agent.define
155        // rejects this combination), but a non-supporting provider must
156        // never get a spurious env var injected.
157        let provider = providers::get_provider("codex").unwrap();
158        let agent = base_agent("https://my-proxy.example.com");
159        assert!(resolve_vendor_env_override(provider, &agent).is_none());
160    }
161}
162
163#[cfg(test)]
164mod resolve_cli_args_tests {
165    use super::*;
166
167    #[test]
168    fn persistent_controller_uses_persistent_launch_args() {
169        let provider = providers::get_provider("claude").unwrap();
170        assert_eq!(
171            resolve_cli_args(provider, "persistent"),
172            provider.persistent_launch_args.unwrap().to_vec()
173        );
174    }
175
176    #[test]
177    fn subprocess_controller_uses_the_default_launch_args() {
178        let provider = providers::get_provider("codex").unwrap();
179        assert_eq!(resolve_cli_args(provider, "subprocess"), provider.launch_args.to_vec());
180    }
181
182    /// ReAgent P1 on PR #3215: this is the bug fix. Before it, an
183    /// `"app-server"` controller_type fell into the `else` branch and got
184    /// Codex's one-shot `exec --json` args — the App Server child would spawn
185    /// but never see `--listen stdio://`, so it never speaks the App Server
186    /// protocol and the handshake in `AppServerController::start` hangs until
187    /// `INITIALIZE_TIMEOUT`.
188    #[test]
189    fn app_server_controller_uses_the_app_server_launch_args_not_the_one_shot_ones() {
190        let provider = providers::get_provider("codex").unwrap();
191        let app_server_args = provider.app_server.as_ref().unwrap().launch_args.to_vec();
192
193        let resolved = resolve_cli_args(provider, "app-server");
194
195        assert_eq!(resolved, app_server_args);
196        assert_ne!(
197            resolved,
198            provider.launch_args.to_vec(),
199            "must not silently fall back to the one-shot exec args"
200        );
201    }
202
203    /// Defensive: a provider with no `app_server` config (everything except
204    /// Codex today) must not panic if `controller_type` is somehow
205    /// `"app-server"` for it — fall back to the ordinary launch args instead.
206    #[test]
207    fn app_server_controller_type_falls_back_gracefully_for_a_provider_without_one() {
208        let provider = providers::get_provider("claude").unwrap();
209        assert!(provider.app_server.is_none());
210        assert_eq!(resolve_cli_args(provider, "app-server"), provider.launch_args.to_vec());
211    }
212}
213
214/// The agent `agent.open` opens: a user agent (`is_seeded == 0`), named by
215/// definition id or by display name (ASCII case-insensitive) — its id first,
216/// then its name. Only when no user agent answers is a template consulted,
217/// and then only to refuse it with `TEMPLATE_NOT_OPENABLE`: opening one would
218/// launch a session that is no agent's (spec §6.5.8, template sessions); an
219/// agent is created from a template in the picker. A template's id is its
220/// lowercase name (`claude`), which a user agent created from it shares
221/// case-insensitively, so templates must never be matched first.
222fn select_openable_agent<'a>(
223    agents: &'a [AgentDefinition],
224    wanted: &str,
225) -> Result<&'a AgentDefinition, String> {
226    let names = |a: &&AgentDefinition| a.id == wanted || a.name.eq_ignore_ascii_case(wanted);
227    let users = || agents.iter().filter(|a| a.is_seeded == 0);
228    let found = users()
229        .find(|a| a.id == wanted)
230        .or_else(|| users().find(|a| a.name.eq_ignore_ascii_case(wanted)))
231        .or_else(|| agents.iter().find(names))
232        .ok_or_else(|| format!("AGENT_NOT_FOUND: no agent definition with id '{wanted}'"))?;
233    if found.is_seeded != 0 {
234        return Err(format!(
235            "TEMPLATE_NOT_OPENABLE: '{}' is a template; agent.open opens an agent from My Agents — create an agent from this template in the agent picker first",
236            found.name
237        ));
238    }
239    Ok(found)
240}
241
242/// Identity M4b-4 (spec §6.5.8): a user agent's local row, backfilled from
243/// the shared registry if it is another channel's — and **confirmed**, or the
244/// open fails: without a local row the spawn would resolve to nothing, with
245/// no UID/token and no credential gate, exactly the process M4b closes (Codex
246/// P1 on #3584). `agent_def_list` listed the agent, so a missing row here is a
247/// failed backfill, not a missing agent.
248fn require_local_agent_row(mstore: &Store, agent_id: &str) -> Result<(), String> {
249    match mstore.agent_row_ensure_local(agent_id) {
250        Ok(true) => Ok(()),
251        Ok(false) => Err(format!(
252            "AGENT_ROW_UNAVAILABLE: could not create a local row for agent '{agent_id}' — not started"
253        )),
254        Err(e) => Err(format!(
255            "AGENT_ROW_UNAVAILABLE: local row for agent '{agent_id}' could not be read or created ({e}) — not started"
256        )),
257    }
258}
259
260/// Identity M4b-4 (spec §6.5.8): record `agent.open`'s launch of user agent
261/// `agent_id` on `block_id` — a lifecycle-only update, never
262/// `instance_create` with a fresh record (whose fold would blank the agent's
263/// account, bundle and workspace) — and stamp the block with the row id.
264/// A row that is gone (the agent deleted mid-open — the delete handlers do
265/// not take `AGENT_OPEN_LOCKS`) or a failed write **fails the open**: the
266/// spawn would otherwise resolve to nothing, tokenless and past the
267/// credential gate (Codex P1 on #3584). A failed stamp is only logged — for a
268/// user agent the block's `agentId` already names its row.
269fn record_agent_open_launch(mstore: &Store, agent_id: &str, block_id: &str) -> Result<(), String> {
270    let now = agentmux_common::time::now_ms();
271    match mstore.instance_record_launch(agent_id, block_id, now) {
272        Ok(true) => {
273            let mut stamp = obj::MetaMapType::new();
274            stamp.insert("agentInstanceId".to_string(), json!(agent_id));
275            if let Err(e) = crate::server::service::update_object_meta(
276                mstore,
277                &format!("block:{block_id}"),
278                &stamp,
279            ) {
280                tracing::warn!(agent_id, block_id, error = %e, "agent.open: stamping the block failed");
281            }
282        }
283        Ok(false) => {
284            return Err(format!(
285                "AGENT_ROW_UNAVAILABLE: agent '{agent_id}' has no local row to record the launch on (deleted?) — not started"
286            ));
287        }
288        Err(e) => {
289            return Err(format!(
290                "AGENT_ROW_UNAVAILABLE: recording the launch of agent '{agent_id}' failed ({e}) — not started"
291            ));
292        }
293    }
294    Ok(())
295}
296
297pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
298    register_agent_open(engine, state);
299}
300
301fn register_agent_open(engine: &Arc<WshRpcEngine>, state: &AppState) {
302    let app_state = state.clone();
303
304    engine.register_typed(COMMAND_AGENT_OPEN, move |cmd: CommandAgentOpenData, _ctx| {
305        let app_state = app_state.clone();
306        async move { open_agent_impl(&app_state, cmd).await }
307    });
308}
309
310/// The `agent.open` implementation — load the definition, resolve the
311/// provider/CLI, create the block, enqueue the layout insert, and register
312/// the controller. Extracted from the RPC closure so the HTTP surface
313/// (`POST /api/v1/agent/open`, backing the `OpenAgent` MCP tool) shares this
314/// exact code path instead of re-deriving it: the TOCTOU serialization via
315/// `AGENT_OPEN_LOCKS` (reagent P1 on PR #2059), the ABF provider shadowing,
316/// the resume-session seeding guard, and the CLI path resolution all live
317/// here once. Same extraction shape as `open_pane` / `handle_pane_open`.
318/// See docs/reports/REPORT_AGENT_OPEN_API_GAP_2026_09_06.md.
319pub(crate) async fn open_agent_impl(
320    state: &AppState,
321    cmd: CommandAgentOpenData,
322) -> Result<AgentOpenResult, String> {
323    open_agent_inner(state, cmd, None).await
324}
325
326/// `agent.open`, but launching into an existing, empty agent pane
327/// (`{view:"agent"}`, the picker) instead of creating a new one and
328/// inserting it into the layout. Used by Layouts → Open layout…
329/// (`SPEC_LAYOUT_FILES_2026_09_25.md` §3.5), which rebuilds the saved split
330/// tree first and then launches each agent into its placeholder — so every
331/// check `agent.open` makes (admission, one live instance, provider, CLI,
332/// resume, config files) applies unchanged. If the agent is already open in
333/// this tab, that pane is returned and the placeholder is left as a picker.
334pub(crate) async fn open_agent_into_block(
335    state: &AppState,
336    agent_id: &str,
337    tab_id: &str,
338    block_id: &str,
339) -> Result<AgentOpenResult, String> {
340    open_agent_inner(
341        state,
342        CommandAgentOpenData {
343            agent_id: agent_id.to_string(),
344            tab_id: Some(tab_id.to_string()),
345            split_direction: None,
346            split_reference_block_id: None,
347            focus: Some(false),
348        },
349        Some(block_id),
350    )
351    .await
352}
353
354async fn open_agent_inner(
355    state: &AppState,
356    cmd: CommandAgentOpenData,
357    into_block: Option<&str>,
358) -> Result<AgentOpenResult, String> {
359    let mstore = state.mstore.clone();
360    let broker = state.broker.clone();
361    let event_bus = state.event_bus.clone();
362    let filestore = state.filestore.clone();
363    // Owned handle so the reducer-dispatch sites below read exactly as they
364    // did inside the RPC closure (which captured an owned clone).
365    let app_state = state.clone();
366
367    tracing::info!(agent_id = %cmd.agent_id, "agent.open");
368
369                // 1. Load the agent definition (by id or name)
370                let agents = mstore.agent_def_list()
371                    .map_err(|e| format!("agent.open: {e}"))?;
372                let mut agent = select_openable_agent(&agents, &cmd.agent_id)?.clone();
373
374                // Settle `agent.provider` once through the shared resolver
375                // (the agent's own, or its bundle's when it has none; see
376                // Store::resolve_effective_provider_id) instead of at every
377                // downstream read in this handler. MUST be
378                // app_state.id_store, never mstore.
379                agent.provider = app_state.id_store.resolve_effective_provider_id(&agent);
380
381                // Serialize the rest of this handler per agent definition —
382                // held until the function returns (guard drops at every exit
383                // path, success or error). See AGENT_OPEN_LOCKS' doc comment.
384                let open_lock = agent_open_lock(&agent.id);
385                let _open_guard = open_lock.lock().await;
386
387                // One live instance per agent across every AgentMux instance
388                // on this host (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24
389                // Phase 1, I9) — checked before this handler writes anything
390                // on the agent's behalf. The in-process checks below only see
391                // this srv; this sees the others.
392                crate::backend::agent_admission::check_before_spawn(
393                    crate::backend::agent_admission::lease_store_for(mstore.shared_agent_registry()),
394                    &agent.id,
395                    &agent.name,
396                    &app_state.boot_id,
397                )
398                .await
399                .map_err(|refusal| format!("AGENT_LIVE_ELSEWHERE: {refusal}"))?;
400
401                // Identity M4b-4 (spec §6.5.8): a user agent known only from
402                // the shared registry (another channel's) gets its local row
403                // before any spawn, so its block resolves to it. Templates
404                // were refused above.
405                require_local_agent_row(&mstore, &agent.id)?;
406
407                // 2. Resolve provider
408                let provider = providers::get_provider(&agent.provider)
409                    .ok_or_else(|| format!("INVALID_PROVIDER: unknown provider '{}'", agent.provider))?;
410
411                // 3. Determine tab
412                let tab_id = resolve_tab_id(&mstore, cmd.tab_id.as_deref())?;
413
414                // 4. Check for existing agent pane in this tab (idempotent)
415                // Use resolved agent.id (not raw user input which could be a name)
416                if let Some(existing) = find_agent_block(&mstore, &tab_id, &agent.id)? {
417                    // Ensure the controller is registered (may be missing if block
418                    // was created by the frontend without backend initialization)
419                    if blockcontroller::get_controller(&existing.oid).is_none() {
420                        let controller_type = if agent.agent_type == "container" {
421                            "subprocess"
422                        } else {
423                            provider.controller_type_str()
424                        };
425                        // Set essential metadata if missing
426                        let mut meta_update = obj::MetaMapType::new();
427                        meta_update.insert("controller".to_string(), json!(controller_type));
428                        meta_update.insert("agentProvider".to_string(), json!(&agent.provider));
429                        let _ = crate::server::service::update_object_meta(
430                            &mstore, &format!("block:{}", existing.oid), &meta_update,
431                        );
432                        // Identity M4b-4: record the launch before the
433                        // resync — a controller can report its session id
434                        // during it, and that capture finds the row by
435                        // block (Codex P2 on #3584). Only on this branch's
436                        // resync, never for a pane that is already live.
437                        record_agent_open_launch(&mstore, &agent.id, &existing.oid)?;
438                        // Register controller
439                        let block_for_resync = mstore.must_get::<Block>(&existing.oid)
440                            .map_err(|e| format!("agent.open: reload block: {e}"))?;
441                        let _ = blockcontroller::resync_controller(
442                            &block_for_resync, &tab_id, None, true, true,
443                            Some(broker.clone()), Some(event_bus.clone()), Some(mstore.clone()),
444                            Some(filestore.clone()),
445                            Some(app_state.id_store.clone()), Some(app_state.identity_store.clone()),
446                            mstore.shared_agent_registry(),
447                            app_state.boot_id.clone(), &app_state.auth_key,
448                            Some(app_state.config_watcher.clone()),
449                        );
450                    }
451                    let status = blockcontroller::get_block_controller_status(&existing.oid)
452                        .map(|s| s.shellprocstatus)
453                        .unwrap_or_else(|| "init".to_string());
454                    return Ok(AgentOpenResult {
455                        block_id: existing.oid,
456                        tab_id,
457                        agent_id: cmd.agent_id,
458                        provider: agent.provider,
459                        controller_type: provider.controller_type_str().to_string(),
460                        status,
461                        created: false,
462                    });
463                }
464
465                // 5. Resolve CLI path.
466                //
467                // The SAME lookup as `resolvecli` / `install.start` /
468                // `install.check`: the shared pinned-version dir, then the
469                // legacy per-AgentMux-version one (`backend::cli_install`).
470                //
471                // History worth keeping: this once hand-rolled
472                // `$HOME/.agentmux/<version>/cli/...` — a path that never
473                // existed — so `agent.open` failed with `CLI_NOT_AVAILABLE`
474                // for every agent even with the CLI installed (verified live
475                // 2026-09-01). One shared resolver is what keeps the launch
476                // paths from drifting apart again.
477                let paths = agentmux_common::DataPaths::from_env()
478                    .ok_or_else(|| "DataPaths::from_env() failed".to_string())?;
479                let npm_bin = crate::backend::cli_install::npm_bin(
480                    &crate::backend::cli_install::install_dir(&paths, &provider.id, provider.pinned_version),
481                    provider.cli_command,
482                )
483                .to_string_lossy()
484                .to_string();
485                let mut resolved_cli_path = crate::backend::cli_install::find_installed(
486                    &paths,
487                    &provider.id,
488                    provider.pinned_version,
489                    provider.cli_command,
490                )
491                .map(|p| p.to_string_lossy().to_string())
492                .unwrap_or_else(|| npm_bin.clone());
493                if !std::path::Path::new(&resolved_cli_path).exists() {
494                    // Fallback: provider not installed via npm — try system PATH.
495                    // This is used for Python-based CLIs like Kimi that are not
496                    // distributed on npm.
497                    if provider.npm_package.is_empty() {
498                        if let Some(path) = resolve_provider_cli_on_path(provider.id, provider.cli_command).await {
499                            resolved_cli_path = path;
500                        }
501                    }
502                    if !std::path::Path::new(&resolved_cli_path).exists() {
503                        // An npm-backed provider whose pinned CLI isn't there yet
504                        // (a fresh install, or the minute or two after an upgrade
505                        // moved the pin): install it, as the UI's launch flow does,
506                        // instead of making the caller go and do that first.
507                        if provider.npm_package.is_empty() || provider.pinned_version.is_empty() {
508                            return Err(format!(
509                                "CLI_NOT_AVAILABLE: {} not found at {}. Install it, then try again.",
510                                provider.cli_command, npm_bin
511                            ));
512                        }
513                        let req = crate::backend::cli_install::NpmInstallRequest {
514                            provider_id: provider.id.to_string(),
515                            npm_package: provider.npm_package.to_string(),
516                            pinned_version: provider.pinned_version.to_string(),
517                            cli_command: provider.cli_command.to_string(),
518                            background: false,
519                        };
520                        match crate::backend::cli_install::ensure_installed(
521                            paths.clone(),
522                            req,
523                            std::time::Duration::from_secs(AGENT_OPEN_INSTALL_WAIT_SECS),
524                        )
525                        .await
526                        {
527                            Ok(bin) => resolved_cli_path = bin.to_string_lossy().to_string(),
528                            Err(crate::backend::cli_install::EnsureError::StillInstalling) => {
529                                return Err(format!(
530                                    "CLI_INSTALLING: {} {} is still being installed. Try again in a minute; \
531                                     the install carries on in the background.",
532                                    provider.cli_command, provider.pinned_version
533                                ));
534                            }
535                            Err(crate::backend::cli_install::EnsureError::Failed(why)) => {
536                                return Err(format!(
537                                    "CLI_NOT_AVAILABLE: {} {} could not be installed: {why}",
538                                    provider.cli_command, provider.pinned_version
539                                ));
540                            }
541                        }
542                    }
543                }
544
545                // 6. Build metadata
546                let controller_type = provider.controller_type_str();
547                // Container agents use per-turn docker exec; the subprocess controller
548                // is required regardless of what the provider defaults to (claude returns
549                // "persistent", which causes AgentInput to skip the container exec path).
550                let controller_type = if agent.agent_type == "container" {
551                    "subprocess"
552                } else {
553                    controller_type
554                };
555                // Catalog args, the runtime the menu will show applied on top, then the
556                // definition's own flags. Without the middle step a pane opened here
557                // ran on the CLI's default model while its menu read Sonnet — see
558                // backend/agent_runtime.rs.
559                //
560                // What the user last picked in the menu for this agent sits above the
561                // definition's flags (and replaces them in this pane's copy), so
562                // reopening an agent starts where it was left. A read failure only
563                // means "nothing remembered".
564                let remembered = crate::backend::agent_runtime::Remembered::parse(
565                    provider.id,
566                    &mstore.agent_last_runtime_get(&agent.id).unwrap_or_default(),
567                );
568                let (base_args, pane_flags, remembered_mode) = crate::backend::agent_runtime::apply_remembered(
569                    provider.id,
570                    resolve_cli_args(&provider, controller_type),
571                    &agent.provider_flags,
572                    &remembered,
573                );
574                let mut seeded =
575                    crate::backend::agent_runtime::seed_launch(provider.id, base_args, &pane_flags);
576                if let (Some(mode), Some(rt)) = (remembered_mode, seeded.runtime.as_mut()) {
577                    rt["permissionMode"] = json!(mode);
578                }
579
580                let agent_slug = agentmux_common::slug::path_slug(&agent.name);
581                // Shared with native-memory resolution so the two can never
582                // disagree about where a blank-working_directory agent
583                // actually runs — they did, and Personal Bundle broke for the
584                // common case as a result (ReAgent/Codex P1, PR #2901; see
585                // SPEC_FIX_PERSONAL_MEMORY_EMPTY_WORKDIR_2026_09_01.md).
586                let work_dir = if agent.working_directory.is_empty() {
587                    crate::backend::storage::agents::default_agent_working_dir(&agent.name)
588                } else {
589                    agent.working_directory.clone()
590                };
591
592                // Build env vars
593                let mut env_vars = serde_json::Map::new();
594                for key in provider.unset_env {
595                    env_vars.insert(key.to_string(), json!(""));
596                }
597                // Auth dir — the DEFAULT provider auth lives in the shared,
598                // instance/channel/version-independent providers area so a single
599                // login is shared everywhere (the structural fix for the per-channel
600                // validate-spin regression). The per-account auth dir
601                // (identity_handlers) still wins for explicit multi-account.
602                let auth_dir = providers::default_auth_dir(provider)?;
603                // Create the dir AND apply its isolation guarantees in one
604                // inseparable step. Claude Code CLI's own user-level CLAUDE.md
605                // discovery falls through to the real $HOME/.claude/CLAUDE.md
606                // when this isolated dir has none of its own — CLAUDE_CONFIG_DIR
607                // relocates credential/session/project storage but NOT that
608                // lookup (proven live, three-arm experiment:
609                // docs/reports/REPORT_CLAUDE_CONFIG_DIR_ISOLATION_EVIDENCE_2026_09_01.md).
610                //
611                // Fail closed (block the spawn) rather than warn-and-continue:
612                // continuing would launch the agent with exactly the
613                // unprotected condition this exists to close (Codex P1 +
614                // ReAgent P1, PR #2854). Non-claude providers get the dir
615                // created and nothing else, so this never blocks their spawns.
616                // See SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md.
617                providers::prepare_provider_auth_dir(provider, &auth_dir).map_err(|e| {
618                    format!(
619                        "failed to isolate this agent's Claude Code config ({auth_dir}): {e}. \
620                         Refusing to launch with an unprotected config dir."
621                    )
622                })?;
623                env_vars.insert(provider.auth_config_dir_env_var.to_string(), json!(auth_dir));
624                for (k, v) in provider.auth_extra_env {
625                    env_vars.insert(k.to_string(), json!(v));
626                }
627                // Model vendor override (harness vs. model-vendor decoupling)
628                // — e.g. ANTHROPIC_BASE_URL for a claude-provider agent
629                // pointed at a proxy/Bedrock/OpenRouter instead of
630                // Anthropic's default endpoint. Inserted before the
631                // free-form env blob below so it wins if a definition-level
632                // KEY=VALUE line collides with it (structured field over
633                // free-form blob, same precedence as the auth entries above).
634                if let Some((var, value)) = resolve_vendor_env_override(provider, &agent) {
635                    env_vars.insert(var.to_string(), json!(value));
636                }
637                // Merge env vars from the definition's persisted env content blob (KEY=VALUE lines).
638                // Provider/auth entries inserted above take precedence; definition-level vars
639                // are merged after so they can extend (but not override) the auth env.
640                if let Ok(Some(env_blob)) = mstore.agent_content_get(&agent.id, "env") {
641                    for line in env_blob.content.lines() {
642                        if let Some((k, v)) = line.split_once('=') {
643                            let k = k.trim();
644                            if !k.is_empty() && !env_vars.contains_key(k) {
645                                env_vars.insert(k.to_string(), json!(v));
646                            }
647                        }
648                    }
649                }
650                // Agent identity. `GH_CONFIG_DIR` is not persisted here: it is
651                // a reserved variable set on every spawn by `backend::gh_guard`,
652                // which this env reaches through `build_persistent_spawn_env`.
653                // Use stored slug (stable across renames) for muxbus routing;
654                // fall back to the computed slug derived from the display name.
655                let routing_id = if !agent.slug.is_empty() { &agent.slug } else { &agent_slug };
656                env_vars.insert("AGENTMUX_AGENT_ID".to_string(), json!(routing_id));
657                // Mirror under MUXBUS_AGENT_ID too, matching the same
658                // additive fix in agent_handlers/input.rs (ARCH-002) --
659                // this MuxAPI path (agent.open -> agent.send, via
660                // agent_io.rs reading this back from persisted cmd:env)
661                // builds its own env_vars independently of input.rs's spawn
662                // path, so it needs the same mirror applied here rather than
663                // inheriting it (codex P2 on PR #2345).
664                env_vars.insert("MUXBUS_AGENT_ID".to_string(), json!(routing_id));
665                // Exit delay only for subprocess
666                if controller_type != "persistent" {
667                    env_vars.insert("CLAUDE_CODE_EXIT_AFTER_STOP_DELAY".to_string(), json!("30000"));
668                }
669
670                // Cross-tab/cross-restart continuity: this agent may already have
671                // a captured provider session sitting in the shared registry
672                // (backfilled from its provider transcript, or captured live by a
673                // prior block) even though no block for it exists in THIS tab —
674                // e.g. after a full app restart lands on a different/rehydrated
675                // tab. Seed the new block's agent:sessionid meta so its FIRST
676                // turn resumes that conversation instead of silently starting
677                // fresh and orphaning the original — the same thing the
678                // picker's explicit "Continue" flow already does via
679                // continueOfInstanceId (RecentSessionRow.session_id), extended
680                // here to the default open path.
681                // See docs/retro/retro-cross-channel-conversation-continuity-regression-2026-06-16.md
682                // ("Mechanism 2 — continuity"), action item 4.
683                //
684                // Concurrency guard (reagent P1 on PR #2059): only seed
685                // agent:sessionid when no OTHER block for this same agent
686                // definition currently has a LIVE controller registered
687                // anywhere in this process — not just this tab (find_agent_block
688                // above only scoped the "reuse" check to the target tab).
689                // Without this, opening the same named agent in a second tab
690                // while the first is still live would seed the new block with
691                // the SAME session_id, letting two controllers concurrently
692                // `--resume` one provider session — risking transcript
693                // corruption or exactly the orphaning bug this fix exists to
694                // prevent. A block with no registered controller (e.g. right
695                // after an app restart, before anything has resynced) is not
696                // "live" and doesn't block seeding.
697                // A block of this agent that is mid-close has already left
698                // CONTROLLER_REGISTRY but its process may still be exiting
699                // (up to the close's grace period). Wait for it, so the check
700                // below sees it gone AND no second `--resume` starts on the
701                // session while the old process still holds it
702                // (SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md §9.6).
703                let closing_blocks: Vec<String> = mstore
704                    .get_all::<Block>()
705                    .map(|blocks| {
706                        blocks
707                            .into_iter()
708                            .filter(|b| {
709                                obj::meta_get_string(&b.meta, "agentId", "") == agent.id
710                                    && blockcontroller::is_closing(&b.oid)
711                            })
712                            .map(|b| b.oid)
713                            .collect()
714                    })
715                    .unwrap_or_default();
716                for closing in &closing_blocks {
717                    blockcontroller::wait_closing_stopped(
718                        closing,
719                        blockcontroller::SHUTDOWN_GRACE + std::time::Duration::from_secs(2),
720                    )
721                    .await;
722                }
723                let agent_live_elsewhere = mstore.get_all::<Block>()
724                    .map(|blocks| {
725                        blocks.iter().any(|b| {
726                            obj::meta_get_string(&b.meta, "agentId", "") == agent.id
727                                && blockcontroller::get_controller(&b.oid).is_some()
728                        })
729                    })
730                    .unwrap_or(false);
731                let resume_session_id: Option<String> = if agent_live_elsewhere {
732                    None
733                } else {
734                    mstore.shared_agent_registry()
735                        .and_then(|reg| reg.list_active().ok())
736                        .and_then(|records| {
737                            records.into_iter()
738                                .filter(|r| r.data.definition_id == agent.id)
739                                .filter(|r| r.data.session_id.as_deref().map_or(false, |s| !s.is_empty()))
740                                .max_by_key(|r| r.data.last_launched_at_ms)
741                        })
742                        .and_then(|r| r.data.session_id)
743                };
744
745                let mut meta = MetaMapType::new();
746                meta.insert("view".to_string(), json!("agent"));
747                meta.insert("agentId".to_string(), json!(&agent.id));
748                // Per-agent zoom persistence (SPEC_AGENT_ZOOM_PERSISTENCE): seed
749                // the new block's `term:zoom` from the agent's saved `ui:zoom`
750                // (per-agent content store, global cross-channel) so reopening
751                // the same agent restores its zoom instead of resetting to 1.0.
752                // Stored only for non-default zooms; clamp to the frontend's
753                // [0.5, 2.0] range so a corrupt value can't escape it.
754                if let Ok(Some(c)) = mstore.agent_content_get(&agent.id, "ui:zoom") {
755                    if let Some(z) = parse_seed_zoom(&c.content) {
756                        meta.insert("term:zoom".to_string(), json!(z));
757                    }
758                }
759                // Per-agent color (SPEC_AGENT_COLOR_2026_08_08.md): seed the
760                // new block's frame border colors from the agent's stored
761                // ui:color so the existing pane-frame rendering shows it —
762                // full-strength on the focused border
763                // (frame:activebordercolor), dimmed on the unfocused one
764                // (frame:bordercolor) so the color is visible either way
765                // while focus stays distinguishable by brightness.
766                // Assign-if-missing write-through covers defs created by
767                // paths the createagent handler doesn't own (forks,
768                // imports) and any def that predates migration m0020 on
769                // this channel. Strict #rrggbb validation so a corrupt row
770                // can't inject arbitrary CSS.
771                {
772                    use crate::backend::agent_color::{dim_agent_color, is_valid_agent_color, pick_agent_color};
773                    let stored = mstore
774                        .agent_content_get(&agent.id, "ui:color")
775                        .ok()
776                        .flatten()
777                        .map(|c| c.content.trim().to_string())
778                        .filter(|c| is_valid_agent_color(c));
779                    let color = match stored {
780                        Some(c) => c,
781                        None => {
782                            let picked = pick_agent_color(&agent.id).to_string();
783                            let now_ms = agentmux_common::time::now_ms();
784                            // Best-effort: a store error here shouldn't block
785                            // opening the agent — the pane just stays uncolored
786                            // this session and we retry next open.
787                            let _ = mstore.agent_content_set(&crate::backend::storage::store::AgentContent {
788                                agent_id: agent.id.clone(),
789                                content_type: "ui:color".to_string(),
790                                content: picked.clone(),
791                                updated_at: now_ms,
792                            });
793                            picked
794                        }
795                    };
796                    meta.insert("frame:activebordercolor".to_string(), json!(&color));
797                    meta.insert("frame:bordercolor".to_string(), json!(dim_agent_color(&color)));
798                }
799                meta.insert("agentProvider".to_string(), json!(&agent.provider));
800                meta.insert("agentName".to_string(), json!(&agent.name));
801                meta.insert("agentIcon".to_string(), json!(if agent.icon.is_empty() { "wand-magic-sparkles" } else { &agent.icon }));
802                meta.insert("agentMode".to_string(), json!(if agent.agent_type.is_empty() { "host" } else { &agent.agent_type }));
803                if !agent.container_image.is_empty() {
804                    meta.insert("agent:container_image".to_string(), json!(&agent.container_image));
805                }
806                if agent.container_volumes != "[]" && !agent.container_volumes.is_empty() {
807                    meta.insert("agent:container_volumes".to_string(), json!(&agent.container_volumes));
808                }
809                // Container-local CLI command: the provider's CLI as it resolves
810                // INSIDE the image (on the image's PATH, e.g. `claude`). Distinct
811                // from `cmd` below, which is the host-resolved absolute npm path
812                // (`<config_home>/.../node_modules/.bin/claude`) and does NOT exist
813                // in the container — passing it as docker-exec argv[0] would fail
814                // with "no such file or directory". Container turns use this.
815                meta.insert("agent:container_command".to_string(), json!(provider.cli_command));
816                // The provider's own format, as the UI launch writes it
817                // (`agent-model.ts`). A hard-coded map here stamped every
818                // provider it didn't list (Antigravity, the ACP ones) as Claude.
819                meta.insert("agentOutputFormat".to_string(), json!(provider.styled_output_format));
820                meta.insert("controller".to_string(), json!(controller_type));
821                meta.insert("cmd".to_string(), json!(&resolved_cli_path));
822                // cmd:args + agent:runtime + agent:provider_flags, written together.
823                crate::backend::agent_runtime::apply_to_meta(&mut meta, seeded, &pane_flags);
824                meta.insert("cmd:cwd".to_string(), json!(&work_dir));
825                meta.insert("cmd:env".to_string(), serde_json::Value::Object(env_vars));
826                meta.insert("agent:resume_flag".to_string(), json!(provider.resume_flag.unwrap_or("")));
827                meta.insert("agent:resume_strategy".to_string(), json!(provider.resume_strategy_str()));
828                meta.insert("agent:session_id_field".to_string(), json!(provider.session_id_field));
829                meta.insert("agent:prompt_arg_flag".to_string(), json!(provider.prompt_arg_flag().unwrap_or("")));
830                if let Some(sid) = &resume_session_id {
831                    meta.insert("agent:sessionid".to_string(), json!(sid));
832                }
833
834                // 7. Create block + insert into layout tree.
835                // Through the reducer (#1681), not wcore-direct: a store-only
836                // block is invisible to the reducer-canonical `state.blocks`
837                // (only hydrated from SQLite at bootstrap), so tearing this agent
838                // pane off later was rejected "block not found". BlockCreated
839                // carries meta → apply_block_created writes the mstore Block,
840                // which the controller resync below reloads by id.
841                let meta_val = serde_json::to_value(&meta)
842                    .map_err(|e| format!("agent.open: meta serialize: {e}"))?;
843                let block_id = if let Some(target) = into_block {
844                    // Layouts → Open layout…: the pane already exists and sits
845                    // in the rebuilt tree — give it the agent's meta through
846                    // the reducer (like `pane.open`'s editor reuse) and skip
847                    // the layout insert below.
848                    let target_block = mstore
849                        .get::<Block>(target)
850                        .map_err(|e| format!("agent.open: read target pane: {e}"))?
851                        .ok_or_else(|| format!("agent.open: no such pane: {target}"))?;
852                    if obj::meta_get_string(&target_block.meta, "view", "") != "agent"
853                        || !obj::meta_get_string(&target_block.meta, "agentId", "").is_empty()
854                    {
855                        return Err("agent.open: the target pane is not an empty agent pane".to_string());
856                    }
857                    let mut patch = meta_val.clone();
858                    if let Some(p) = patch.as_object_mut() {
859                        // The layout's "which agent goes here" hint is spent.
860                        p.insert(crate::backend::layout_file::META_LAYOUT_AGENT.to_string(), serde_json::Value::Null);
861                    }
862                    let meta_events = crate::server::service::dispatch_to_reducer(
863                        &app_state,
864                        agentmux_common::ipc::Command::UpdateBlockMeta { block_id: target.to_string(), meta_patch: patch },
865                    )
866                    .await;
867                    if let Some(msg) = meta_events.iter().find_map(|e| match e {
868                        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
869                        _ => None,
870                    }) {
871                        return Err(format!("agent.open: UpdateBlockMeta: {msg}"));
872                    }
873                    for ev in &meta_events {
874                        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
875                            tracing::warn!("agent.open: UpdateBlockMeta mstore apply failed: {e}");
876                        }
877                    }
878                    crate::server::service::publish_events(&app_state, &meta_events);
879                    target.to_string()
880                } else {
881                let create_events = crate::server::service::dispatch_to_reducer(
882                    &app_state,
883                    agentmux_common::ipc::Command::CreateBlock {
884                        tab_id: tab_id.clone(),
885                        meta: meta_val,
886                    },
887                )
888                .await;
889                if let Some(msg) = create_events.iter().find_map(|e| match e {
890                    agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
891                    _ => None,
892                }) {
893                    return Err(format!("agent.open: CreateBlock: {msg}"));
894                }
895                let block_id = create_events
896                    .iter()
897                    .find_map(|e| match e {
898                        agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
899                            Some(block_id.clone())
900                        }
901                        _ => None,
902                    })
903                    .ok_or_else(|| "agent.open: CreateBlock emitted no BlockCreated".to_string())?;
904                for ev in &create_events {
905                    if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &mstore) {
906                        tracing::warn!("agent.open: CreateBlock mstore apply failed: {e}");
907                    }
908                }
909                crate::server::service::publish_events(&app_state, &create_events);
910
911                // Enqueue a layout insert action for the frontend to process.
912                // The frontend's LayoutModel watches pendingbackendactions on the
913                // LayoutState and applies them via treeReducer — same mechanism
914                // used by cross-window drag-and-drop (dnd.rs).
915                // SPEC_864 Phase 4 — append through the reducer (single
916                // writer of db_layout). Best-effort like the store-direct
917                // write it replaces.
918                {
919                    let action = obj::LayoutActionData {
920                        actiontype: "insert".to_string(),
921                        actionid: uuid::Uuid::new_v4().to_string(),
922                        blockid: block_id.clone(),
923                        nodesize: None,
924                        nodesizefraction: None,
925                        indexarr: None,
926                        // Honors the request's `focus` (default true — the UI
927                        // flow's behavior). Was hardcoded `true` while the RPC
928                        // surface was the only caller and nothing set the
929                        // field; this PR documents `focus`/`--no-focus` on
930                        // OpenAgent/muxopen, so ignoring it would silently
931                        // contradict their own help text. reagent P1, PR #3032.
932                        focused: cmd.focus.unwrap_or(true),
933                        magnified: false,
934                        ephemeral: false,
935                        targetblockid: String::new(),
936                        position: String::new(),
937                    };
938                    if let Err(e) = crate::server::service::queue_layout_actions_via_reducer(
939                        &app_state,
940                        &tab_id,
941                        vec![action],
942                    )
943                    .await
944                    {
945                        tracing::warn!("agent.open: layout action enqueue failed: {e}");
946                    }
947                }
948                block_id
949                };
950
951                tracing::info!(
952                    block_id = %block_id,
953                    agent_id = %cmd.agent_id,
954                    provider = %agent.provider,
955                    controller_type = %controller_type,
956                    "agent.open: block created + layout updated"
957                );
958
959                // 8. Write agent config files. No collision resolution
960                //    in this path — the function creates the dir if
961                //    missing and overwrites whatever's there. Same-
962                //    name same-hour launches will share a workdir;
963                //    proper allocation is tracked as a follow-up.
964                write_agent_config_files(&mstore, &app_state.id_store, &app_state.identity_store, &agent, routing_id, &work_dir)?;
965
966                // Identity M4b-4: record the launch and stamp the block before
967                // the resync — a controller can report its session id during
968                // it, and that capture finds the row by block (Codex P2 on
969                // #3584).
970                record_agent_open_launch(&mstore, &agent.id, &block_id)?;
971
972                // 9. Register controller (resync)
973                let block_for_resync = mstore.must_get::<Block>(&block_id)
974                    .map_err(|e| format!("agent.open: reload block: {e}"))?;
975                blockcontroller::resync_controller(
976                    &block_for_resync,
977                    &tab_id,
978                    None,
979                    true,
980                    true,
981                    Some(broker.clone()),
982                    Some(event_bus.clone()),
983                    Some(mstore.clone()),
984                    Some(filestore.clone()),
985                    Some(app_state.id_store.clone()),
986                    Some(app_state.identity_store.clone()),
987                    mstore.shared_agent_registry(),
988                    app_state.boot_id.clone(),
989                    &app_state.auth_key,
990                    Some(app_state.config_watcher.clone()),
991                )?;
992
993                // 10. Broadcast block + tab + layout updates to frontend
994                {
995                    let mut updates = Vec::new();
996                    if let Ok(updated_block) = mstore.must_get::<Block>(&block_id) {
997                        updates.push(obj::MuxObjUpdate {
998                            updatetype: "update".into(),
999                            otype: "block".into(),
1000                            oid: block_id.clone(),
1001                            obj: Some(obj::mux_obj_to_value(&updated_block)),
1002                        });
1003                    }
1004                    if let Ok(updated_tab) = mstore.must_get::<Tab>(&tab_id) {
1005                        updates.push(obj::MuxObjUpdate {
1006                            updatetype: "update".into(),
1007                            otype: "tab".into(),
1008                            oid: tab_id.clone(),
1009                            obj: Some(obj::mux_obj_to_value(&updated_tab)),
1010                        });
1011                        if let Ok(updated_layout) = mstore.must_get::<obj::LayoutState>(&updated_tab.layoutstate) {
1012                            updates.push(obj::MuxObjUpdate {
1013                                updatetype: "update".into(),
1014                                otype: "layout".into(),
1015                                oid: updated_tab.layoutstate.clone(),
1016                                obj: Some(obj::mux_obj_to_value(&updated_layout)),
1017                            });
1018                        }
1019                    }
1020                    // One batched frame so the renderer applies all of them in
1021                    // a single reactive flush — see
1022                    // EventBus::broadcast_mux_obj_updates.
1023                    event_bus.broadcast_mux_obj_updates(&updates);
1024                }
1025
1026                // codex P2 on PR #3513: this used to be safe to hardcode --
1027                // before eager resume existed, a freshly created pane could
1028                // only ever be `STATUS_INIT` at this point. Now that
1029                // `resync_controller` above is handed real identity stores,
1030                // a pane seeded with a registry-derived `agent:sessionid`
1031                // (picker reattach, an agent definition with prior history)
1032                // can come back already `running`. Read the real status
1033                // the same way the existing-pane branch above does, rather
1034                // than tell the caller "init" for a pane that's actually
1035                // live.
1036                let status = blockcontroller::get_block_controller_status(&block_id)
1037                    .map(|s| s.shellprocstatus)
1038                    .unwrap_or_else(|| "init".to_string());
1039                Ok(AgentOpenResult {
1040                    block_id,
1041                    tab_id,
1042                    agent_id: cmd.agent_id,
1043                    provider: agent.provider,
1044                    controller_type: controller_type.to_string(),
1045                    status,
1046                    created: true,
1047                })
1048}
1049
1050/// Write agent config files (CLAUDE.md, .mcp.json, etc.) to the working directory.
1051pub(super) fn write_agent_config_files(
1052    mstore: &Store,
1053    id_store: &Store,
1054    identity_store: &Store,
1055    agent: &crate::backend::storage::AgentDefinition,
1056    agent_slug: &str,
1057    work_dir: &str,
1058) -> Result<(), String> {
1059    // Load agent content
1060    let contents = mstore.agent_content_get_all(&agent.id)
1061        .unwrap_or_default();
1062
1063    let mut content_map = std::collections::HashMap::new();
1064    for fc in &contents {
1065        content_map.insert(fc.content_type.clone(), fc.content.clone());
1066    }
1067
1068    // Disable autonomous memory writes only when using the bare slug-fallback workdir
1069    // (~/.agentmux/agents/<slug>) — that path is shared across same-name multi-tab
1070    // launches with no collision resolution, risking concurrent MEMORY.md corruption
1071    // (upstream issue #29051). An empty working_directory field means the caller
1072    // chose the fallback; any explicitly set workdir is isolated and keeps writes on.
1073    let workdir_is_shared = agent.working_directory.is_empty();
1074    if workdir_is_shared {
1075        let settings_str = content_map
1076            .entry("settings".to_string())
1077            .or_insert_with(|| "{}".to_string());
1078        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(settings_str) {
1079            Ok(mut obj) => {
1080                obj.entry("autoMemoryEnabled".to_string())
1081                    .or_insert(json!(false));
1082                *settings_str =
1083                    serde_json::to_string(&obj).unwrap_or_else(|_| "{}".to_string());
1084            }
1085            Err(e) => {
1086                tracing::warn!(
1087                    work_dir = %work_dir,
1088                    error = %e,
1089                    "write_agent_config_files: settings JSON unparseable; \
1090                     autoMemoryEnabled guard skipped — memory writes may be active on shared workdir"
1091                );
1092            }
1093        }
1094    }
1095
1096    // Global Memory (minus Operator Config for another agent kind), then the
1097    // agent's picked bundles in list order, go into CLAUDE.md; WriteAgentConfig
1098    // composes the same text (SPEC_RENAME_KNOWLEDGE_TO_MEMORY_2026_10_06.md §3.6).
1099    let global_bundles = crate::backend::operator_config_seed::global_bundles_for_agent(
1100        id_store.bundle_list_global().unwrap_or_default(),
1101        &agent.agent_type,
1102    );
1103    let startup_block = crate::backend::storage::join_startup_blocks(
1104        &crate::backend::storage::format_global_bundle_block(&global_bundles),
1105        &crate::backend::storage::format_agent_bundle_block(&crate::backend::storage::agent_picked_bundles(
1106            mstore, id_store, &agent.id,
1107        )),
1108    );
1109    if !startup_block.is_empty() {
1110        content_map
1111            .entry("memory".to_string())
1112            .and_modify(|existing| {
1113                *existing = format!("{startup_block}\n\n---\n\n{existing}");
1114            })
1115            .or_insert(startup_block);
1116    }
1117
1118    // v1 skills: globals are always injected; the agent's OWN ref-bound skills
1119    // are authoritative when present, otherwise fall back to legacy
1120    // db_agent_skills. See Store::effective_skills for the merge algorithm —
1121    // shared with the `listagentskills` RPC handler so the frontend's
1122    // pre-launch skill fetch and this materialization path never diverge.
1123    let effective_skills = mstore.effective_skills(identity_store, &agent.id);
1124
1125    let mut config_files = crate::backend::agent_config::build_config_files(
1126        &content_map,
1127        &effective_skills,
1128        &agent.name,
1129        &agent.id,
1130        agent_slug,
1131        work_dir,
1132        &agent.provider,
1133    );
1134
1135    // v1 MCP: "agentmux" plus the agent's effective servers. The
1136    // legacy blob's user servers are merged in ONLY when the agent has no own
1137    // ref-bound servers (so a global server never wipes a legacy-only agent's
1138    // .mcp.json). When the agent has own refs, those are authoritative.
1139    // A bundle carries no MCP servers
1140    // (SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md §3.1), so bundles
1141    // add none.
1142    let visible_mcp: Vec<crate::backend::storage::McpServer> = mstore.effective_mcp_servers(identity_store, &agent.id, agent.agent_type == "container");
1143    let has_own_mcp_refs = visible_mcp.iter().any(|server| !server.is_global);
1144    if !visible_mcp.is_empty() {
1145        let blob_for_merge = if has_own_mcp_refs {
1146            None
1147        } else {
1148            content_map.get("mcp").map(|s| s.as_str())
1149        };
1150        if let Some(mcp_json) = crate::backend::agent_config::build_mcp_config_from_refs(
1151            &visible_mcp,
1152            blob_for_merge,
1153            agent_slug,
1154            &agent.agent_bus_id,
1155        ) {
1156            if let Some(pos) = config_files.iter().position(|f| f.filename == ".mcp.json") {
1157                config_files[pos].content = mcp_json;
1158            } else {
1159                config_files.push(crate::backend::agent_config::AgentConfigFile {
1160                    filename: ".mcp.json".to_string(),
1161                    content: mcp_json,
1162                });
1163            }
1164        }
1165    }
1166
1167    // Host-tier + LAN-tier jekt sender signing keys
1168    // (SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2,
1169    // SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.1) — inject
1170    // AGENTMUX_JEKT_KEY / AGENTMUX_LAN_KEY into the agentmux MCP server's
1171    // own env, right alongside AGENTMUX_AGENT_ID, so `SendMessage`/`Loop`
1172    // can sign outgoing jekts as this agent. Best-effort: a failure here
1173    // must never block agent spawn — the agent still launches, just
1174    // without a key, and its jekts render TRUST=self-declared /
1175    // TRUST=network-claimed instead of verified until the next successful
1176    // materialization. Shared with `WriteAgentConfig`
1177    // (server/editor_handlers.rs, the "click Launch" path) via
1178    // `agent_config::inject_jekt_signing_keys_into_mcp_json` — see that
1179    // function's doc comment for why this must not be duplicated inline
1180    // again.
1181    if let Some(pos) = config_files.iter().position(|f| f.filename == ".mcp.json") {
1182        crate::server::agent_self_keys::record_config_id_drift("m4d.config_id_drift.agent_open", agent_slug, Some(&agent.slug));
1183        match crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
1184            &config_files[pos].content,
1185            &mstore,
1186            agent_slug,
1187        ) {
1188            Some(rewritten) => config_files[pos].content = rewritten,
1189            None => {
1190                tracing::warn!(
1191                    agent_id = %agent.id,
1192                    "agent_open: jekt/LAN signing keys not injected into .mcp.json — \
1193                     this agent's jekts will render TRUST=self-declared / TRUST=network-claimed \
1194                     instead of verified"
1195                );
1196            }
1197        }
1198    }
1199
1200    // Expand ~ in work_dir
1201    let expanded_dir = if work_dir.starts_with("~/") || work_dir == "~" {
1202        if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
1203            format!("{}/{}", home, work_dir.trim_start_matches("~/"))
1204        } else {
1205            work_dir.to_string()
1206        }
1207    } else {
1208        work_dir.to_string()
1209    };
1210
1211    let base_path = std::path::Path::new(&expanded_dir);
1212    if !base_path.exists() {
1213        std::fs::create_dir_all(base_path) // workdir-fs: creates the workdir itself
1214            .map_err(|e| format!("failed to create working dir: {e}"))?;
1215    }
1216    let wd = crate::backend::workdir_fs::Workdir::open(base_path)
1217        .map_err(|e| format!("failed to open working dir {}: {e}", base_path.display()))?;
1218
1219    // Remove skill-derived files (.claude/commands/*.md, .claude/skills/*/SKILL.md)
1220    // that WE wrote on a previous launch but aren't part of this run's output --
1221    // e.g. a skill's format switched between "prompt" and "agent-skill", or a
1222    // skill was renamed/removed. Shared with `writeagentconfig` (editor_handlers.rs)
1223    // so the two RPC paths that materialize config files never drift out of sync
1224    // on this (reagent P1 + Codex P1/P2, PR #2322).
1225    let new_managed_skill_paths = crate::backend::agent_config::managed_skill_file_paths(
1226        config_files.iter().map(|f| f.filename.as_str()),
1227    );
1228    crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &new_managed_skill_paths);
1229
1230    for file in &config_files {
1231        // CLAUDE.md never gets the generic write below — it goes through
1232        // ownership-aware materialization instead, which never overwrites
1233        // a pre-existing, non-AgentMux-authored file's content. See
1234        // docs/specs/SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md.
1235        if file.filename == "CLAUDE.md" {
1236            if let Err(e) =
1237                crate::backend::agent_config::write_claude_md_respecting_ownership(base_path, &file.content)
1238            {
1239                return Err(format!("failed to write CLAUDE.md: {e}"));
1240            }
1241            continue;
1242        }
1243        // Every OTHER provider's startup-instructions filename (AGENTS.md,
1244        // GEMINI.md, QWEN.md, .pi/APPEND_SYSTEM.md) never overwrites a
1245        // pre-existing file — codex P1, PR #2788: unlike CLAUDE.md's full
1246        // ownership-aware materialization above, this is a plain
1247        // exists-guard (see write_startup_instructions_respecting_existing's
1248        // own doc comment for why a fuller mechanism isn't attempted here).
1249        if crate::backend::providers::is_known_startup_instructions_filename(&file.filename) {
1250            crate::backend::agent_config::write_startup_instructions_respecting_existing(
1251                base_path,
1252                &file.filename,
1253                &file.content,
1254            )
1255            .map_err(|e| format!("failed to write {}: {e}", file.filename))?;
1256            continue;
1257        }
1258        // `.mcp.json` merges into a user's own file instead of replacing it,
1259        // and is written owner-only (#3680) — see the function's doc comment.
1260        if file.filename == ".mcp.json" {
1261            crate::backend::agent_config::write_mcp_json_respecting_user_servers(base_path, &file.content)
1262                .map_err(|e| format!("failed to write .mcp.json: {e}"))?;
1263            continue;
1264        }
1265        // Through the workdir (SPEC_WORKDIR_SAFE_WRITES_2026_10_01): a path
1266        // outside it, under a folder linking out of it, or onto a symlink is
1267        // refused and skipped, like an escaping path always was here.
1268        match wd.write(&file.filename, file.content.as_bytes(), false) {
1269            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1270                tracing::warn!(
1271                    work_dir = %expanded_dir,
1272                    path = %file.filename,
1273                    error = %e,
1274                    "write_agent_config_files: refusing to write a config path outside the working directory"
1275                );
1276            }
1277            other => other.map_err(|e| format!("failed to write {}: {e}", file.filename))?,
1278        }
1279    }
1280
1281    crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &new_managed_skill_paths);
1282
1283    // Record what this agent will read as project instructions, now that
1284    // AgentMux's own files are in their final state for this launch — observed
1285    // before the write, this would record the previous launch's content.
1286    //
1287    // Phase 3 of SPEC_INSTRUCTION_AND_MEMORY_PORTABILITY_2026_09_09.md.
1288    // Observing at open is the spec's §8 decision 2: it costs nothing, catches
1289    // the case that matters (the repository changed since last launch), and
1290    // avoids a watcher. Drift *during* a session is deliberately not covered
1291    // yet — revisit only if it shows up in practice.
1292    //
1293    // Best-effort: an agent must never fail to launch because a tracking row
1294    // could not be written.
1295    observe_project_instructions(mstore, agent, &expanded_dir);
1296
1297    tracing::info!(
1298        agent_id = %agent.id,
1299        work_dir = %expanded_dir,
1300        file_count = config_files.len(),
1301        "agent.open: wrote config files"
1302    );
1303
1304    Ok(())
1305}
1306
1307/// Resolve and record this agent's project instructions.
1308///
1309/// Split out so the launch path reads as one line and this can be tested on
1310/// its own. Never returns an error: see the call site.
1311pub(super) fn observe_project_instructions(
1312    mstore: &crate::backend::storage::store::Store,
1313    agent: &crate::backend::storage::AgentDefinition,
1314    working_dir: &str,
1315) {
1316    use crate::backend::storage::project_instructions::ProjectInstructionObservation;
1317
1318    let files = crate::backend::project_instructions::resolve_project_instructions(
1319        &agent.provider,
1320        working_dir,
1321    );
1322    if files.is_empty() {
1323        return;
1324    }
1325    let now = agentmux_common::time::now_ms();
1326    let observations: Vec<ProjectInstructionObservation> = files
1327        .iter()
1328        .map(|f| ProjectInstructionObservation {
1329            path: f.path.clone(),
1330            content_hash: f.content_hash.clone(),
1331            size_bytes: f.size_bytes as i64,
1332            owner: match f.owner {
1333                crate::backend::project_instructions::InstructionOwner::Agentmux => "agentmux",
1334                crate::backend::project_instructions::InstructionOwner::Foreign => "foreign",
1335            }
1336            .to_string(),
1337            existed: f.exists,
1338            observed_at: now,
1339        })
1340        .collect();
1341
1342    if let Err(e) = mstore.project_instructions_record(&agent.id, &observations) {
1343        tracing::warn!(
1344            agent_id = %agent.id, error = %e,
1345            "agent.open: could not record project-instruction observations"
1346        );
1347    }
1348}
1349
1350#[cfg(test)]
1351mod write_agent_config_files_tests {
1352    use super::*;
1353    use crate::backend::storage::Skill;
1354
1355    fn make_store() -> Store {
1356        Store::open_in_memory().unwrap()
1357    }
1358
1359    fn make_agent(id: &str, working_directory: &str) -> AgentDefinition {
1360        AgentDefinition {
1361            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1362            id: id.to_string(),
1363            slug: String::new(),
1364            name: "Test Agent".to_string(),
1365            icon: String::new(),
1366            provider: "claude".to_string(),
1367            description: String::new(),
1368            working_directory: working_directory.to_string(),
1369            shell: String::new(),
1370            provider_flags: String::new(),
1371            auto_start: 0,
1372            restart_on_crash: 0,
1373            idle_timeout_minutes: 0,
1374            created_at: 1_700_000_000_000,
1375            agent_type: "host".to_string(),
1376            environment: String::new(),
1377            agent_bus_id: String::new(),
1378            is_seeded: 0,
1379            accounts: String::new(),
1380            parent_id: String::new(),
1381            branch_label: String::new(),
1382            updated_at: 1_700_000_000_000,
1383            user_hidden: 0,
1384            container_image: String::new(),
1385            container_volumes: "[]".to_string(),
1386            container_name: String::new(),
1387            use_ambient_login: 0,
1388            auto_continue_enabled: 0,
1389            model_vendor_base_url: String::new(),
1390            memory_id: String::new(),
1391        }
1392    }
1393
1394    fn make_skill(skill_type: &str) -> Skill {
1395        Skill {
1396            id: "skill-1".to_string(),
1397            name: "Deploy".to_string(),
1398            trigger: "deploy".to_string(),
1399            skill_type: skill_type.to_string(),
1400            description: "Deploy the app".to_string(),
1401            content: "1. Test\n2. Deploy".to_string(),
1402            is_global: false,
1403            created_at: 1_700_000_000_000,
1404            updated_at: 1_700_000_000_000,
1405        }
1406    }
1407
1408    /// reagent P1 + Codex P1/P2, PR #2322: switching a skill's `skill_type`
1409    /// between "prompt" and "agent-skill" must remove the artifact from the
1410    /// PREVIOUS format, not just write the new one -- otherwise the stale
1411    /// file stays active alongside the newly selected format.
1412    #[test]
1413    fn switching_skill_format_removes_the_stale_artifact() {
1414        let mstore = make_store();
1415        let id_store = make_store();
1416        let work_dir = tempfile::tempdir().unwrap();
1417        let work_dir_str = work_dir.path().to_str().unwrap();
1418
1419        let mut agent = make_agent("agent-1", work_dir_str);
1420        mstore.agent_def_insert(&mut agent).unwrap();
1421        mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1422
1423        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1424        let skill_md = work_dir.path().join(".claude/skills/deploy/SKILL.md");
1425        assert!(skill_md.exists(), "expected .claude/skills/deploy/SKILL.md to be written");
1426
1427        // Flip the same skill to "prompt" format and relaunch.
1428        let mut prompt_skill = make_skill("prompt");
1429        prompt_skill.updated_at = 1_700_000_000_001;
1430        mstore.skill_upsert_unique(&mstore, "agent-1", &prompt_skill, true).unwrap();
1431        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1432
1433        let command_md = work_dir.path().join(".claude/commands/deploy.md");
1434        assert!(command_md.exists(), "expected .claude/commands/deploy.md to be written");
1435        assert!(
1436            !skill_md.exists(),
1437            "stale .claude/skills/deploy/SKILL.md must be removed when the skill switches to prompt format"
1438        );
1439        assert!(
1440            !skill_md.parent().unwrap().exists(),
1441            "the now-empty .claude/skills/deploy/ directory should be cleaned up too"
1442        );
1443    }
1444
1445    /// A file the user hand-authored under .claude/commands or .claude/skills
1446    /// (never part of any AgentMux-written manifest) must never be deleted by
1447    /// the stale-cleanup pass.
1448    #[test]
1449    fn user_authored_files_outside_the_manifest_are_never_touched() {
1450        let mstore = make_store();
1451        let id_store = make_store();
1452        let work_dir = tempfile::tempdir().unwrap();
1453        let work_dir_str = work_dir.path().to_str().unwrap();
1454
1455        let mut agent = make_agent("agent-1", work_dir_str);
1456        mstore.agent_def_insert(&mut agent).unwrap();
1457
1458        let user_file = work_dir.path().join(".claude/commands/my-own-command.md");
1459        std::fs::create_dir_all(user_file.parent().unwrap()).unwrap();
1460        std::fs::write(&user_file, "# hand-authored, not from AgentMux").unwrap();
1461
1462        mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1463        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1464        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1465
1466        assert!(user_file.exists(), "hand-authored file outside AgentMux's manifest must survive");
1467    }
1468
1469    /// Defense-in-depth: even if a manifest somehow contains a path-traversal
1470    /// entry (e.g. from a bypass of the trigger sanitization added upstream
1471    /// in agent_config.rs, or manual tampering with the manifest file
1472    /// itself), the cleanup pass must never delete anything outside the
1473    /// agent's working directory (reagent P1, PR #2322).
1474    #[test]
1475    fn cleanup_refuses_to_delete_a_manifest_path_that_escapes_the_working_dir() {
1476        let mstore = make_store();
1477        let id_store = make_store();
1478        let work_dir = tempfile::tempdir().unwrap();
1479        let work_dir_str = work_dir.path().to_str().unwrap();
1480
1481        let mut agent = make_agent("agent-1", work_dir_str);
1482        mstore.agent_def_insert(&mut agent).unwrap();
1483
1484        // A sentinel file OUTSIDE the working directory that a traversal
1485        // attempt would target.
1486        let sentinel_dir = tempfile::tempdir().unwrap();
1487        let sentinel = sentinel_dir.path().join("outside-file.txt");
1488        std::fs::write(&sentinel, "must survive").unwrap();
1489
1490        // Simulate a manifest that (however it got there) records an escape.
1491        std::fs::create_dir_all(work_dir.path().join(".claude")).unwrap();
1492        let traversal_rel = format!(
1493            "../{}/outside-file.txt",
1494            sentinel_dir.path().file_name().unwrap().to_str().unwrap()
1495        );
1496        let malicious_manifest = serde_json::to_string(&vec![traversal_rel]).unwrap();
1497        std::fs::write(
1498            work_dir.path().join(crate::backend::agent_config::MANAGED_SKILL_FILES_MANIFEST),
1499            malicious_manifest,
1500        )
1501        .unwrap();
1502
1503        // No skills at all this run, so the manifest's one entry is "stale"
1504        // and would normally be deleted.
1505        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1506
1507        assert!(sentinel.exists(), "file outside the working directory must never be deleted");
1508    }
1509
1510    /// A `.claude` that links outside the workdir: the generic file loop
1511    /// (skill files, settings) used to write straight through it, with only
1512    /// a lexical check (SPEC_WORKDIR_SAFE_WRITES_2026_10_01 §1). Now refused
1513    /// and skipped, and the launch still succeeds.
1514    #[test]
1515    fn config_files_are_never_written_through_a_claude_folder_linking_outside() {
1516        let mstore = make_store();
1517        let id_store = make_store();
1518        let work_dir = tempfile::tempdir().unwrap();
1519        let work_dir_str = work_dir.path().to_str().unwrap();
1520        let outside = tempfile::tempdir().unwrap();
1521        #[cfg(unix)]
1522        let linked = std::os::unix::fs::symlink(outside.path(), work_dir.path().join(".claude")).is_ok();
1523        #[cfg(windows)]
1524        let linked = std::os::windows::fs::symlink_dir(outside.path(), work_dir.path().join(".claude")).is_ok();
1525        if !linked {
1526            return; // this OS user can't create links
1527        }
1528
1529        let mut agent = make_agent("agent-1", work_dir_str);
1530        mstore.agent_def_insert(&mut agent).unwrap();
1531        mstore.skill_upsert_unique(&mstore, "agent-1", &make_skill("agent-skill"), true).unwrap();
1532        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1533
1534        let leaked: Vec<_> = walkdir_files(outside.path());
1535        assert!(leaked.is_empty(), "nothing may be written outside the workdir, found {leaked:?}");
1536    }
1537
1538    fn walkdir_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
1539        let mut out = Vec::new();
1540        for entry in std::fs::read_dir(dir).unwrap().flatten() {
1541            let p = entry.path();
1542            if p.is_dir() {
1543                out.extend(walkdir_files(&p));
1544            } else {
1545                out.push(p);
1546            }
1547        }
1548        out
1549    }
1550
1551    /// A bundle carries no MCP servers
1552    /// (SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md §3.1): a bundle ref
1553    /// an older build left behind neither reaches `.mcp.json` nor counts as
1554    /// the agent's own, so the agent's legacy blob is still merged.
1555    #[test]
1556    fn a_leftover_bundle_mcp_ref_neither_launches_nor_drops_the_agents_legacy_mcp_blob() {
1557        let mstore = make_store();
1558        let id_store = make_store();
1559        let work_dir = tempfile::tempdir().unwrap();
1560        let work_dir_str = work_dir.path().to_str().unwrap();
1561
1562        let mut agent = make_agent("agent-1", work_dir_str);
1563        agent.memory_id = "bundle-1".to_string();
1564        mstore.agent_def_insert(&mut agent).unwrap();
1565
1566        mstore
1567            .bundle_upsert(&crate::backend::storage::bundles::Bundle {
1568                id: "bundle-1".to_string(),
1569                name: "Bundle 1".to_string(),
1570                description: String::new(),
1571                is_blank: false,
1572                is_global: false,
1573                provider: "claude".to_string(),
1574                model: "anthropic".to_string(),
1575                instructions: String::new(),
1576                instructions_by_provider: "{}".to_string(),
1577                context_files: "[]".to_string(),
1578                mcp_servers: "[]".to_string(),
1579                skills: "[]".to_string(),
1580                sort_order: 0,
1581                created_at: 1_700_000_000_000,
1582                updated_at: 1_700_000_000_000,
1583                is_system: false,
1584            })
1585            .unwrap();
1586        leftover_bundle_mcp_ref(&mstore, "bundle-1", "bundle-private-server", "bundle-server", "bundle-tool");
1587        // agent-1 itself has NO own db_mcp_servers ref — only its bundle does.
1588
1589        mstore
1590            .agent_content_set(&crate::backend::storage::content::AgentContent {
1591                agent_id: "agent-1".to_string(),
1592                content_type: "mcp".to_string(),
1593                content: r#"{"mcpServers":{"legacy-server":{"command":"legacy-tool"}}}"#.to_string(),
1594                updated_at: 1_700_000_000_000,
1595            })
1596            .unwrap();
1597
1598        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1599
1600        let mcp_json_path = work_dir.path().join(".mcp.json");
1601        let mcp_json = std::fs::read_to_string(&mcp_json_path).unwrap();
1602        let parsed: serde_json::Value = serde_json::from_str(&mcp_json).unwrap();
1603        assert!(
1604            parsed["mcpServers"].get("legacy-server").is_some(),
1605            "a leftover bundle ref must not drop the agent's legacy .mcp.json blob: {mcp_json}"
1606        );
1607        assert!(
1608            parsed["mcpServers"].get("bundle-server").is_none(),
1609            "a bundle's server must not launch: {mcp_json}"
1610        );
1611    }
1612
1613    /// A bundle MCP ref of the kind an older build wrote; nothing writes one now.
1614    fn leftover_bundle_mcp_ref(store: &Store, bundle_id: &str, id: &str, name: &str, command: &str) {
1615        store
1616            .mcp_server_insert_raw(&crate::backend::storage::McpServer {
1617                id: id.to_string(),
1618                name: name.to_string(),
1619                transport: "stdio".to_string(),
1620                config: format!(r#"{{"command":"{command}"}}"#),
1621                is_global: false,
1622                created_at: 1_700_000_000_000,
1623                updated_at: 1_700_000_000_000,
1624            })
1625            .unwrap();
1626        store
1627            .conn()
1628            .lock()
1629            .unwrap()
1630            .execute(
1631                "INSERT INTO db_bundle_mcp_ref (bundle_id, mcp_id) VALUES (?1, ?2)",
1632                rusqlite::params![bundle_id, id],
1633            )
1634            .unwrap();
1635    }
1636
1637    fn make_bundle(id: &str, name: &str, instructions: &str, is_global: bool) -> crate::backend::storage::bundles::Bundle {
1638        crate::backend::storage::bundles::Bundle {
1639            id: id.to_string(),
1640            name: name.to_string(),
1641            description: String::new(),
1642            is_blank: false,
1643            is_global,
1644            provider: String::new(),
1645            model: String::new(),
1646            instructions: instructions.to_string(),
1647            instructions_by_provider: "{}".to_string(),
1648            context_files: "[]".to_string(),
1649            mcp_servers: "[]".to_string(),
1650            skills: "[]".to_string(),
1651            sort_order: 0,
1652            created_at: 1_700_000_000_000,
1653            updated_at: 1_700_000_000_000,
1654            is_system: false,
1655        }
1656    }
1657
1658    /// The bundles picked for an agent reach its launch
1659    /// (SPEC_RENAME_KNOWLEDGE_TO_MEMORY_2026_10_06.md §3.6): their
1660    /// instructions as `# [Bundle]` sections after Global Memory, in list
1661    /// order; a listed bundle that no longer exists is skipped. Not MCP
1662    /// servers: a bundle carries none.
1663    #[test]
1664    fn picked_bundles_reach_the_startup_file_but_not_the_mcp_config() {
1665        let mstore = make_store();
1666        let id_store = make_store();
1667        let work_dir = tempfile::tempdir().unwrap();
1668        let work_dir_str = work_dir.path().to_str().unwrap();
1669
1670        let mut agent = make_agent("agent-1", work_dir_str);
1671        mstore.agent_def_insert(&mut agent).unwrap();
1672        id_store.bundle_upsert(&make_bundle("global-1", "Rules", "global rule", true)).unwrap();
1673        id_store.bundle_upsert(&make_bundle("b1", "Reviewer", "review carefully", false)).unwrap();
1674        id_store.bundle_upsert(&make_bundle("b2", "Writer", "write plainly", false)).unwrap();
1675        leftover_bundle_mcp_ref(&mstore, "b2", "b2-server", "writer-tools", "writer-tool");
1676        mstore
1677            .agent_bundles_set("agent-1", &["b2".to_string(), "gone".to_string(), "b1".to_string()])
1678            .unwrap();
1679
1680        write_agent_config_files(&mstore, &id_store, &mstore, &agent, "test-agent", work_dir_str).unwrap();
1681
1682        let claude_md = std::fs::read_to_string(work_dir.path().join("CLAUDE.md")).unwrap();
1683        let global = claude_md.find("# [Workspace] Rules").expect("Global Memory section");
1684        let writer = claude_md.find("# [Bundle] Writer\n\nwrite plainly").expect("first pick's section");
1685        let reviewer = claude_md.find("# [Bundle] Reviewer\n\nreview carefully").expect("second pick's section");
1686        assert!(global < writer && writer < reviewer, "Global Memory, then the picks in order: {claude_md}");
1687
1688        let mcp_json = std::fs::read_to_string(work_dir.path().join(".mcp.json")).unwrap_or_default();
1689        assert!(!mcp_json.contains("writer-tools"), "a picked bundle's server must not launch: {mcp_json}");
1690    }
1691}
1692
1693#[cfg(test)]
1694mod record_agent_open_launch_tests {
1695    use super::*;
1696    use crate::backend::storage::agents::test_agent_def;
1697
1698    /// Identity M4b-4 (spec §6.5.8): an `agent.open` of a user agent records
1699    /// the launch on its row (lifecycle only) and stamps the block, so the
1700    /// block resolves to that row — the row's UID and token at its spawn.
1701    /// Codex P1 on #3584: an open whose local row cannot be confirmed
1702    /// (here: an agent that exists neither locally nor in a registry) fails
1703    /// instead of spawning row-less; a local agent passes.
1704    #[test]
1705    fn an_open_without_a_confirmed_local_row_is_refused() {
1706        let store = Store::open_in_memory().unwrap();
1707        let err = require_local_agent_row(&store, "agent-nowhere").unwrap_err();
1708        assert!(err.starts_with("AGENT_ROW_UNAVAILABLE"), "{err}");
1709        let mut def = test_agent_def("agent-here", "Here", "claude", "agent", 1, "");
1710        store.agent_def_insert(&mut def).unwrap();
1711        assert!(require_local_agent_row(&store, "agent-here").is_ok());
1712    }
1713
1714    /// Codex P1 on #3584: an agent deleted between the row check and the
1715    /// record (the delete handlers do not take the open lock) fails the open
1716    /// rather than spawning a block that names a missing row.
1717    #[test]
1718    fn an_open_whose_agent_was_deleted_mid_open_is_refused() {
1719        let store = Store::open_in_memory().unwrap();
1720        let mut def = test_agent_def("agent-gone-mid", "GoneMid", "claude", "agent", 1, "");
1721        store.agent_def_insert(&mut def).unwrap();
1722        assert!(require_local_agent_row(&store, "agent-gone-mid").is_ok());
1723        assert!(store.agent_def_delete("agent-gone-mid").unwrap());
1724        let err =
1725            record_agent_open_launch(&store, "agent-gone-mid", &uuid::Uuid::new_v4().to_string())
1726                .unwrap_err();
1727        assert!(err.starts_with("AGENT_ROW_UNAVAILABLE"), "{err}");
1728    }
1729
1730    #[test]
1731    fn an_agent_open_launch_is_recorded_stamped_and_resolves() {
1732        let store = Store::open_in_memory().unwrap();
1733        let mut def = test_agent_def("agent-open-x", "OpenX", "claude", "agent", 1, "");
1734        store.agent_def_insert(&mut def).unwrap();
1735        // ORef::parse, behind update_object_meta, needs a UUID-shaped oid.
1736        let block_id = uuid::Uuid::new_v4().to_string();
1737        let mut block = Block {
1738            oid: block_id.clone(),
1739            parentoref: String::new(),
1740            version: 0,
1741            runtimeopts: None,
1742            stickers: None,
1743            meta: {
1744                let mut m = obj::MetaMapType::new();
1745                m.insert("agentId".to_string(), json!("agent-open-x"));
1746                m
1747            },
1748            subblockids: None,
1749        };
1750        store.insert(&mut block).unwrap();
1751
1752        record_agent_open_launch(&store, "agent-open-x", &block_id).unwrap();
1753
1754        let stamped: Block = store.must_get(&block_id).unwrap();
1755        assert_eq!(
1756            stamped.meta.get("agentInstanceId"),
1757            Some(&json!("agent-open-x"))
1758        );
1759        let row = store.instance_get("agent-open-x").unwrap().unwrap();
1760        assert_eq!(row.block_id, block_id);
1761        assert_eq!(row.status, "running");
1762        let resolved = store
1763            .instance_get_active_for_block(&block_id)
1764            .unwrap()
1765            .map(|r| r.id);
1766        assert_eq!(resolved.as_deref(), Some("agent-open-x"));
1767    }
1768
1769    fn def(id: &str, name: &str, is_seeded: i64) -> AgentDefinition {
1770        let mut d = test_agent_def(id, name, "claude", "agent", 1, "");
1771        d.is_seeded = is_seeded;
1772        d
1773    }
1774
1775    /// `agent.open` opens user agents only: a template is refused by id or by
1776    /// name, and a user agent is preferred over a template it shares a name
1777    /// with — including the template's id, which is its lowercase name
1778    /// (adversarial review of #3600) — whatever the list order.
1779    #[test]
1780    fn agent_open_selects_my_agents_and_refuses_templates() {
1781        let agents = vec![
1782            def("claude", "Claude", 1),
1783            def("agent-mine", "Claude", 0),
1784            def("codex", "Codex", 1),
1785            def("agent-other", "agent-mine", 0),
1786        ];
1787        let pick = |wanted| select_openable_agent(&agents, wanted).map(|a| a.id.as_str());
1788
1789        assert_eq!(pick("agent-mine"), Ok("agent-mine"), "an id wins over a name");
1790        assert_eq!(pick("claude"), Ok("agent-mine"), "not the template's id");
1791        assert_eq!(pick("CLAUDE"), Ok("agent-mine"));
1792        for wanted in ["codex", "Codex"] {
1793            let err = pick(wanted).unwrap_err();
1794            assert!(err.starts_with("TEMPLATE_NOT_OPENABLE"), "{wanted}: {err}");
1795        }
1796        let err = pick("nobody").unwrap_err();
1797        assert!(err.starts_with("AGENT_NOT_FOUND"), "{err}");
1798    }
1799}
1800
1801#[cfg(test)]
1802mod single_live_instance_tests {
1803    use super::*;
1804    use crate::backend::storage::agents::test_agent_def;
1805
1806    /// Every file under the shared registry root except the lease tree, with
1807    /// its bytes — what "nothing shared was written" is measured against.
1808    fn snapshot(root: &std::path::Path) -> Vec<(std::path::PathBuf, Vec<u8>)> {
1809        let mut out = Vec::new();
1810        let mut stack = vec![root.to_path_buf()];
1811        while let Some(dir) = stack.pop() {
1812            for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
1813                let path = entry.path();
1814                if path.file_name().is_some_and(|n| n == "leases") {
1815                    continue;
1816                }
1817                if path.is_dir() {
1818                    stack.push(path);
1819                } else {
1820                    out.push((path.clone(), std::fs::read(&path).unwrap_or_default()));
1821                }
1822            }
1823        }
1824        out.sort();
1825        out
1826    }
1827
1828    /// SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 §7 test 9 (I9): opening an
1829    /// agent another AgentMux instance on this host is running is refused
1830    /// before the handler writes anything — no block, no registry record.
1831    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1832    async fn opening_an_agent_live_in_another_instance_writes_nothing() {
1833        let state = crate::server::tests::test_state();
1834        let tmp = tempfile::tempdir().unwrap();
1835        let registry = Arc::new(crate::registry::Registry::open(tmp.path().join("registry")).unwrap());
1836        state.mstore.set_registry(Arc::clone(&registry));
1837        let uid = uuid::Uuid::new_v4().to_string();
1838        let mut def = test_agent_def(&uid, "Agent3", "claude", "host", 1, "");
1839        state.mstore.agent_def_insert(&mut def).unwrap();
1840
1841        let lease_store =
1842            crate::backend::agent_admission::lease_store_for(Some(Arc::clone(&registry))).unwrap();
1843        let _other = crate::backend::agent_admission::acquire(
1844            &lease_store,
1845            &uid,
1846            "Agent3",
1847            &Arc::from("another-srv-boot"),
1848            "other-block",
1849            None,
1850            |_| {},
1851        )
1852        .unwrap();
1853
1854        let blocks_before = state.mstore.get_all::<Block>().unwrap().len();
1855        let registry_before = snapshot(registry.root());
1856
1857        let err = open_agent_impl(
1858            &state,
1859            CommandAgentOpenData {
1860                agent_id: uid.clone(),
1861                tab_id: None,
1862                split_direction: None,
1863                split_reference_block_id: None,
1864                focus: None,
1865            },
1866        )
1867        .await
1868        .err()
1869        .expect("the open must be refused");
1870
1871        assert!(err.starts_with("AGENT_LIVE_ELSEWHERE: Agent3 is already running"), "{err}");
1872        assert_eq!(state.mstore.get_all::<Block>().unwrap().len(), blocks_before, "no block created");
1873        assert_eq!(snapshot(registry.root()), registry_before, "shared registry untouched");
1874    }
1875}