Skip to main content

agentmux_srv/server/
http_shell.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shell drawer HTTP handlers and the agent shell lock.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9/// `POST /api/v1/shell/create` — start a persistent background shell.
10///
11/// Called by `agentmux-mcp`'s `Shell` tool. Returns immediately with a
12/// `shell_id`; the `ShellNodeRunner` streams stdout/stderr to the frontend
13/// as `shell_chunk` MPS events without blocking the agent.
14pub(super) async fn handle_shell_create(
15    State(state): State<AppState>,
16    Json(req): Json<ShellCreateRequest>,
17) -> impl IntoResponse {
18    // Where it runs. Checked before anything is published, so a refused
19    // connection (no ssh, a distro that isn't installed, a bad name) fails the
20    // call with its reason instead of running the command here.
21    use super::app_api::connections::{self, AgentTarget};
22    let target = match connections::for_agent(&state.broker, req.connection.as_deref()).await {
23        Ok(t) => t,
24        Err(e) => {
25            return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
26        }
27    };
28    // An SSH host runs with the user's identity: their consent first (asked
29    // once, or remembered as "always"), then every ssh prompt goes to them
30    // through the askpass bridge, never to the agent.
31    let mut ssh_agent = String::new();
32    if let AgentTarget::Ssh(dest) = &target {
33        // Before anything is asked: with no askpass helper this cannot run, and
34        // consent (or an "always") given for it would be given for nothing.
35        if crate::backend::remote::askpass::program().is_none() {
36            let e = "this AgentMux has no askpass helper (agentmux-bashwrap) to run an agent's SSH command with";
37            return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
38        }
39        ssh_agent = match connections::verified_agent(&state, &req.agent_block_id, req.auth.as_ref()) {
40            Ok(a) => a,
41            Err(e) => return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response(),
42        };
43        let connection = dest.name();
44        if let Err(e) = connections::consent_for_ssh(&state, &req.agent_block_id, &ssh_agent, &connection, &req.cmd).await {
45            return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response();
46        }
47    }
48    let wsl_distro = match &target {
49        AgentTarget::Wsl(d) => Some(d.clone()),
50        _ => None,
51    };
52    let remote = !matches!(target, AgentTarget::Local);
53
54    let shell_id = uuid::Uuid::new_v4().to_string();
55    let title = req.title.as_deref().unwrap_or(&req.cmd).to_string();
56    let now_ms = agentmux_common::time::now_ms_u64();
57
58    // Read the agent block once for both the cwd and env fallbacks below.
59    let agent_block = state.mstore
60        .get::<crate::backend::obj::Block>(&req.agent_block_id)
61        .ok()
62        .flatten();
63
64    // If cwd wasn't supplied by the caller, fall back to the agent block's
65    // cmd:cwd — the working directory the agent pane was launched with.
66    // Without this, ShellNodeRunner would inherit agentmux-srv's cwd
67    // (typically the portable runtime/ dir) instead of the project dir.
68    //
69    // In WSL or over SSH the cwd is a path on the other side, given as is, and
70    // the agent's own directory is no fallback for it: the remote home is.
71    let effective_cwd = if remote {
72        req.cwd.clone()
73    } else {
74        req.cwd.clone().or_else(|| {
75        agent_block.as_ref().and_then(|block| {
76            let cwd = crate::backend::obj::meta_get_string(&block.meta, "cmd:cwd", "");
77            if cwd.is_empty() { None } else { Some(cwd.to_string()) }
78        })
79        })
80    };
81
82    // Normalize the cwd before it reaches the spawner. Agents on Windows run
83    // inside a bash shell and emit MSYS paths like `/c/Users/user/project`;
84    // passing those straight to `Command::current_dir` fails with os error 267
85    // (ERROR_DIRECTORY). This converts them to native form and expands `~`.
86    let effective_cwd = if remote {
87        effective_cwd
88    } else {
89        effective_cwd.and_then(|c| crate::backend::base::normalize_working_dir(&c))
90    };
91
92    // Env parity with the agent CLI: start from the agent block's stored
93    // cmd:env (the per-agent env the agent process is launched with — same
94    // shape app_api/agent_io.rs / agent_handlers/input.rs read), then let the
95    // caller-supplied req.env override on top (explicit Shell env wins). This
96    // forwards the concrete per-agent env so `Shell(...)` runs with the same
97    // env the agent itself sees, mirroring the cmd:cwd fallback above.
98    //
99    // NOT forwarded here: the dynamic identity bindings
100    // (identity/resolver/inject.rs) and the bundled tools/bin PATH prefix
101    // that `build_persistent_spawn_env` (agent_handlers/input.rs) and
102    // blockcontroller/shell/lifecycle.rs inject at agent-CLI spawn
103    // time — those are resolved live per spawn, not stored in cmd:env. The
104    // MCP server (agentmux-mcp) is itself launched by the agent CLI through
105    // the bundled tools/bin, so tools it spawns inherit that PATH; shells
106    // created here run from agentmux-srv's env plus cmd:env + req.env.
107    let mut effective_env: std::collections::HashMap<String, String> = agent_block
108        .as_ref()
109        .map(|block| crate::backend::blockcontroller::cmd_env_of(&block.meta))
110        .unwrap_or_default();
111    let caller_keys: Vec<String> = req.env.iter().flat_map(|e| e.keys().cloned()).collect();
112    if let Some(req_env) = req.env {
113        effective_env.extend(req_env);
114    }
115    // After the caller's overrides: a command run on an agent's behalf gets the
116    // same plain-`gh` guard as the agent itself, and `req.env` can't lift it.
117    // Nor can it hand that command the account's cloud login.
118    crate::backend::gh_guard::apply_gh_guard(&mut effective_env);
119    crate::backend::account_login_guard::strip_account_login(&mut effective_env);
120    // WSL passes on only what WSLENV names: the caller's own variables and the
121    // gh guard, never the rest of the agent's env.
122    let cwd_there = effective_cwd.as_deref().unwrap_or_default();
123    let mut askpass_secret = None;
124    let launch = match &target {
125        AgentTarget::Local => None,
126        AgentTarget::Wsl(distro) => {
127            effective_env.insert(
128                "WSLENV".to_string(),
129                crate::backend::remote::wsl::wslenv_with(
130                    &std::env::var("WSLENV").unwrap_or_default(),
131                    caller_keys.iter().map(String::as_str),
132                ),
133            );
134            let args = crate::backend::remote::wsl::launch(distro, &req.cmd, &[], cwd_there).args;
135            Some(("wsl.exe".to_string(), args))
136        }
137        AgentTarget::Ssh(dest) => {
138            use crate::backend::remote::{askpass, ssh};
139            let (Some(ssh_path), Some(askpass_program)) = (ssh::binary(), askpass::program()) else {
140                let e = "this AgentMux has no ssh or no askpass helper (agentmux-bashwrap) to run an agent's SSH command with";
141                return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
142            };
143            // Named from AgentMux's own record of the agent and host, never
144            // from the request, whose env the agent controls.
145            let secret = askpass::grant(askpass::AskpassGrant {
146                agent_block_id: req.agent_block_id.clone(),
147                agent: ssh_agent.clone(),
148                connection: dest.name(),
149                user_pane: false,
150            });
151            let local_url = std::env::var("AGENTMUX_LOCAL_URL").unwrap_or_default();
152            effective_env.extend(askpass::ssh_env(&secret, &askpass_program, &local_url, &state.auth_key));
153            askpass_secret = Some(secret);
154            let args = ssh::launch_exec(dest, &req.cmd, cwd_there, ssh::shared_control_dir().as_deref());
155            Some((ssh_path.to_string_lossy().into_owned(), args))
156        }
157    };
158
159    tracing::info!(
160        block_id = %req.agent_block_id,
161        shell_id = %shell_id,
162        cmd = %req.cmd,
163        cwd = ?effective_cwd,
164        wsl = ?wsl_distro,
165        ssh = matches!(target, AgentTarget::Ssh(_)),
166        "shell.create"
167    );
168
169    // Publish shell_node_create so the frontend inserts the row
170    // before the first chunk arrives (avoids a flash of orphaned chunks).
171    // persist: 64 — retain up to 64 shell_node_create events per block scope
172    // so multiple shells in a pane all replay on WS reconnect / pane remount.
173    // (persist: 1 meant only the last shell's create event was kept; earlier
174    // shells lost their create event while their shell_chunk events at
175    // persist: 1024 still replayed, causing the reducer to silently drop
176    // orphaned chunks.)
177    let created = crate::backend::shell_node::ShellNodeCreateEvent {
178        shell_id: shell_id.clone(),
179        cmd: req.cmd.clone(),
180        cwd: effective_cwd.clone(),
181        title: title.clone(),
182        timestamp: now_ms,
183    };
184    state.broker.publish_event::<crate::backend::mps_events::ShellNodeCreate>(&created, vec![format!("block:{}", req.agent_block_id)], 64);
185
186    // Spawn the runner — fire-and-forget; events flow independently.
187    let runner = crate::backend::shell_node::ShellNodeRunner {
188        shell_id: shell_id.clone(),
189        block_id: req.agent_block_id,
190        cmd: req.cmd,
191        title,
192        cwd: if launch.is_some() { None } else { effective_cwd },
193        launch,
194        askpass_secret,
195        extra_env: effective_env,
196        broker: Arc::clone(&state.broker),
197        registry: Arc::clone(&state.shell_sessions),
198        capture_stdin: req.capture_stdin.unwrap_or(false),
199    };
200    tokio::spawn(runner.run());
201
202    (StatusCode::OK, Json(ShellCreateResponse { shell_id })).into_response()
203}
204
205/// `POST /api/v1/shell/stop` — stop a running persistent shell.
206///
207/// Called by `agentmux-mcp`'s `ShellStop` tool. Tree-kills the shell's
208/// process group (so `task dev` → `task.exe`/`node` grandchildren die too),
209/// which makes the runner publish a `stopped` exit event. Returns `{ stopped }`
210/// — false if the id is unknown (never started or already exited).
211pub(super) async fn handle_shell_stop(
212    State(state): State<AppState>,
213    Json(req): Json<ShellStopRequest>,
214) -> impl IntoResponse {
215    let stopped = state.shell_sessions.stop(&req.shell_id);
216    tracing::info!(shell_id = %req.shell_id, stopped, "shell.stop");
217    (StatusCode::OK, Json(json!({ "stopped": stopped })))
218}
219
220/// `POST /api/v1/shell/input` — write text to a running shell's stdin (Phase 3b).
221///
222/// Appends a newline so single answers like "y" work without the caller
223/// needing to know the line discipline. Returns `{ written: false }` if the
224/// shell is not running or the write fails (e.g. the process closed its stdin).
225pub(super) async fn handle_shell_input(
226    State(state): State<AppState>,
227    Json(req): Json<ShellInputRequest>,
228) -> impl IntoResponse {
229    // Non-blocking send to the stdin relay task — no mutex, no risk of
230    // blocking if the child's pipe buffer is full (the relay owns that concern).
231    // resolve_stdin distinguishes "not running" from "running but no captured
232    // stdin" so the caller gets an actionable reason instead of a bare false.
233    let (written, reason) = match state.shell_sessions.resolve_stdin(&req.shell_id) {
234        Ok(tx) => {
235            let mut text = req.text.clone();
236            if !text.ends_with('\n') {
237                text.push('\n');
238            }
239            if tx.send(text).is_ok() {
240                tracing::debug!(shell_id = %req.shell_id, "shell.input: written");
241                (true, None)
242            } else {
243                // Relay task gone / channel closed — process closed its stdin.
244                tracing::debug!(shell_id = %req.shell_id, "shell.input: write failed");
245                (false, Some(ShellInputFailure::WriteFailed))
246            }
247        }
248        Err(failure) => {
249            tracing::debug!(shell_id = %req.shell_id, ?failure, "shell.input: no target");
250            (false, Some(failure))
251        }
252    };
253    (StatusCode::OK, Json(ShellInputResponse { written, reason }))
254}
255
256/// `POST /api/v1/shell/status` — query a shell's running state (Phase 3b).
257///
258/// Returns `{ running, exit_code, line_count }`. If the shell_id is unknown
259/// (never started or never seen by this sidecar), `running` is false and
260/// `exit_code` is absent.
261pub(super) async fn handle_shell_status(
262    State(state): State<AppState>,
263    Json(req): Json<ShellStatusRequest>,
264) -> impl IntoResponse {
265    let s = state.shell_sessions.get_status(&req.shell_id);
266    (StatusCode::OK, Json(ShellStatusResponse {
267        running: s.running,
268        exit_code: s.exit_code,
269        line_count: s.line_count,
270    }))
271}
272
273// Meta key persisting which sub-block is "this pane's one shell" — the same
274// key the human-facing shell drawer uses: `agent-view.tsx` reads it into
275// `AgentShellDrawer`'s `shellSubBlockId` prop, and `AgentShellDrawer.tsx`'s
276// `onSubBlockCreated` handler writes it. `PtyShell` reuses this instead of
277// minting an independent, invisible shell: reusing it means the agent
278// drives the SAME shell a human sees in the drawer, and a drawer opened
279// later attaches to whatever the agent already started, live output and
280// scrollback included.
281pub(super) use crate::backend::blockcontroller::META_KEY_SHELL_SUBBLOCK_ID;
282
283// How long an agent's write (input/resize) locks the shell out from human
284// keyboard input, refreshed on every subsequent write. Deliberately a lease,
285// not an explicit lock/unlock pair: an explicit "release" call is one the
286// agent could simply never make (error, crash, forgetting), which would
287// leave a human locked out indefinitely — the opposite of "unlock as soon
288// as the agent stops." A short, self-expiring window means the frontend
289// needs no server push to release it either — it just compares the
290// timestamp against its own clock.
291pub(super) const AGENT_LOCK_WINDOW_MS: i64 = 4000;
292
293// Meta key holding the lock's absolute expiry (epoch ms). The frontend
294// gates human keyboard input purely on `Date.now() < this`, reactively —
295// no server-side timer to leak or get stuck; a quiet agent just lets the
296// timestamp lapse and control returns on its own.
297// pub(crate): `server::websocket`'s `controllerinput` handler also reads
298// this — see that handler's own doc comment for why.
299pub(crate) const META_KEY_AGENT_LOCK_UNTIL: &str = "term:agentlockuntil";
300
301/// Whether `shell_id` is a real `view:"term"` sub-block PARENTED TO
302/// `agent_block_id` — the actual safety property Codex asked for (PR
303/// #3177): `shell_id` lives in the same block-id namespace as every other
304/// pane, and `Layout` exposes block ids for ordinary panes, so without this
305/// check an agent could point `PtyShellInput`/`PtyShellStop`/etc. at any
306/// controller-backed block (another agent's own CLI pane, a human's
307/// terminal pane) and inject input into it or tear it down. Framed as
308/// parentage rather than "did `PtyShell` create it" (the original,
309/// narrower check) specifically so it also covers the reuse case above: a
310/// shell the HUMAN created via the drawer is just as legitimate a target as
311/// one `PtyShell` created itself, as long as it's this agent's own pane.
312pub(super) fn is_owned_by_agent(
313    mstore: &crate::backend::storage::store::Store,
314    shell_id: &str,
315    agent_block_id: &str,
316) -> bool {
317    mstore
318        .get::<crate::backend::obj::Block>(shell_id)
319        .ok()
320        .flatten()
321        .map(|b| {
322            b.parentoref == format!("block:{agent_block_id}")
323                && b.meta.get("view").and_then(|v| v.as_str()) == Some("term")
324        })
325        .unwrap_or(false)
326}
327
328/// Merge `meta_update` into `block_id`'s meta and broadcast a
329/// `waveobj:update` WS event so every subscribed frontend (the drawer, if
330/// open) picks it up immediately — the same two steps the `setmeta` WS
331/// handler performs (`websocket.rs`'s `COMMAND_SET_META` handler), factored
332/// out since `ptyshell/*` needs the identical pattern from a plain HTTP
333/// handler with no `WshRpcEngine` in scope.
334pub(super) fn broadcast_meta_update(
335    state: &AppState,
336    block_id: &str,
337    meta_update: &crate::backend::obj::MetaMapType,
338) -> Result<(), String> {
339    let oref_str = format!("block:{block_id}");
340    crate::server::service::object_helpers::update_object_meta(&state.mstore, &oref_str, meta_update)?;
341    let block = state
342        .mstore
343        .must_get::<crate::backend::obj::Block>(block_id)
344        .map_err(|e| e.to_string())?;
345    let update = crate::backend::obj::MuxObjUpdate {
346        updatetype: "update".into(),
347        otype: "block".into(),
348        oid: block_id.to_string(),
349        obj: Some(crate::backend::obj::mux_obj_to_value(&block)),
350    };
351    state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, oref_str));
352    Ok(())
353}
354
355// ---------------------------------------------------------------------------
356// PTY shell handlers (docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md)
357//
358// A REAL PTY, unlike the piped `/api/v1/shell/*` family above — for driving
359// genuinely interactive programs (Sharprompt-style wizards, `sudo`, `ssh`,
360// REPLs) that check for a real terminal and refuse or misbehave on a pipe.
361// Reuses `blockcontroller::shell` verbatim (the same controller backing the
362// `term` widget and `AgentShellSubblock.tsx`'s composer-drawer shell) —
363// this is a new agent-facing entry point onto existing PTY plumbing, not a
364// new PTY implementation.
365//
366// Every handler here is a plain HTTP call with no WS/tab dependency, same as
367// `handle_shell_create` above — deliberately NOT wired through the
368// `WshRpcEngine` that `createsubblock`/`controllerresync`/`controllerinput`
369// use (those are instantiated per-WebSocket-connection, so they exist only
370// while a frontend tab is connected). An agent calling these tools gets a
371// live, fully functional PTY regardless of whether any window is open,
372// focused, or rendering the shell's `term` sub-block at all.
373// ---------------------------------------------------------------------------
374
375/// Shared by both the top-level reuse check and the "lost the atomic
376/// claim" fallback in `handle_pty_shell_create`: resync `id`'s controller
377/// and reply, or return `None` if `id` doesn't resolve to a real block
378/// (stale pointer), OR if its shell has already exited, so the caller
379/// falls through to creating a fresh one. A pane has one agent shell: asking
380/// for it, while it runs, on another connection than its own is refused (409),
381/// not answered with the shell on the wrong machine. An exited one is replaced
382/// whatever its connection was.
383pub(super) async fn try_attach_to_existing_shell(
384    state: &AppState,
385    agent_block_id: &str,
386    id: &str,
387    connection: &str,
388) -> Option<axum::response::Response> {
389    let block = state.mstore.get::<crate::backend::obj::Block>(id).ok().flatten()?;
390
391    // Settled before the resync, which would otherwise start a shell on the
392    // block's own connection: a running shell on another connection is refused
393    // and left as it is, and so is one this srv run created and is still
394    // starting (a concurrent create won the claim). One with no controller from
395    // an earlier run (before a srv restart) is replaced, like an exited one,
396    // rather than revived on the old connection. An exited one is replaced
397    // below whatever its connection was.
398    if let Some(conflict) = connection_conflict(&block, connection) {
399        let created_this_run = crate::backend::obj::meta_get_string(
400            &block.meta,
401            META_KEY_PTYSHELL_BOOT_ID,
402            "",
403        ) == *state.boot_id;
404        match blockcontroller::get_block_controller_status(id) {
405            Some(s) if s.shellprocstatus == blockcontroller::STATUS_RUNNING => return Some(conflict),
406            None if created_this_run => return Some(conflict),
407            None => {
408                clear_shell_pointer_if(state, agent_block_id, id);
409                tracing::info!(
410                    block_id = %id,
411                    parent_id = %agent_block_id,
412                    "ptyshell.create: pane's shell is not running and was on another connection — falling through to a fresh shell"
413                );
414                return None;
415            }
416            Some(_) => {}
417        }
418    }
419
420    // Baseline BEFORE resync — see `answer_conpty_handshake_if_seen`'s doc
421    // comment (Codex P1 on PR #3194) for why this matters: the `term` file
422    // is append-only for the block's whole lifetime, so an already-running
423    // reused shell's history almost certainly still contains its own old,
424    // long-since-answered `ESC[6n` from whenever it first started.
425    let baseline_len = state
426        .filestore
427        .stat(id, "term")
428        .ok()
429        .flatten()
430        .map(|info| info.size as usize)
431        .unwrap_or(0);
432
433    let registry = state.mstore.shared_agent_registry();
434    if let Err(e) = blockcontroller::resync_controller(
435        &block,
436        "ptyshell",
437        None,
438        false,
439        // respawn_if_done=false: don't resurrect a shell that already
440        // exited (e.g. the human typed `exit`). The unconditional-respawn
441        // default is correct for crash/backend-restart recovery
442        // (`TermResyncHandler` on the WS path) but wrong here: every
443        // subsequent `PtyShellCreate` against this same pane-shell pointer
444        // would otherwise silently respawn it again, each respawn
445        // appending a fresh shell-startup banner to the pane's
446        // append-only `term` scrollback — indistinguishable from the
447        // exited shell's output looping forever. Checked inside
448        // `resync_controller` itself (one status read, not a separate
449        // check-then-call here) so a shell that exits in the gap between
450        // an earlier check and this call can't slip through and get
451        // respawned anyway (Codex P2 on PR #3225) — see that parameter's
452        // own doc comment.
453        false,
454        Some(Arc::clone(&state.broker)),
455        Some(Arc::clone(&state.event_bus)),
456        Some(Arc::clone(&state.mstore)),
457        Some(Arc::clone(&state.filestore)),
458        Some(Arc::clone(&state.id_store)),
459        Some(Arc::clone(&state.identity_store)),
460        registry,
461        state.boot_id.clone(),
462        &state.auth_key,
463        Some(Arc::clone(&state.config_watcher)),
464    ) {
465        if e == blockcontroller::RESYNC_ERR_ALREADY_EXITED {
466            // Treating an exited shell like a stale pointer (return
467            // `None`) reuses the existing "create a fresh one" fallback in
468            // both callers instead of inventing a new response shape.
469            //
470            // Also clear the now-stale pointer on the agent's own block:
471            // without this, two concurrent `PtyShellCreate` calls against
472            // the same exited pane would both fall through to
473            // `handle_pty_shell_create`'s atomic-claim transaction, both
474            // read the SAME still-set dead pointer, both take the "lost
475            // the race, reuse THEIRS" branch pointing at that same dead
476            // id, both find it exited too, and both independently execute
477            // the non-transactional "vanished, create for real" fallback —
478            // leaving one orphaned live controller and two callers holding
479            // different shell_ids for what's supposed to be one pane's one
480            // shell (Codex P2 on PR #3225). Clearing the pointer first
481            // makes the already-correct atomic claim transaction see
482            // "unclaimed" instead of "claimed by a dead id," so its
483            // existing single-writer serialization (documented on that
484            // transaction) resolves concurrent callers to one winner the
485            // normal way.
486            //
487            // GUARDED, not unconditional (ReAgent P1 on this PR, round 2):
488            // `resync_controller`'s STATUS_DONE read above happens outside
489            // any lock — a concurrent resync with `respawn_if_done=true`
490            // (e.g. the WS `ControllerResyncCommand`/`TermResyncHandler`
491            // path, which always revives a `STATUS_DONE` controller in
492            // place) can respawn THIS SAME shell in the gap between that
493            // read and this clear. Nulling the pointer unconditionally
494            // would then wipe a pointer that's valid again, orphaning a
495            // live, correctly-pointed shell — exactly the one-shell-per-
496            // pane invariant this PR exists to protect. Re-read the parent
497            // fresh and only clear if its pointer still names `id`, the
498            // same compare-before-clear pattern this file already uses a
499            // few hundred lines down for the identical class of race (see
500            // the `parent.meta.get(META_KEY_SHELL_SUBBLOCK_ID) ==
501            // Some(child_id.as_str())` guard in the spawn-failure rollback
502            // path). Not fully transactional (same as that existing
503            // pattern) — a failed/skipped clear just costs one more round
504            // trip through the fallback chain, not correctness.
505            clear_shell_pointer_if(state, agent_block_id, id);
506            tracing::info!(
507                block_id = %id,
508                parent_id = %agent_block_id,
509                "ptyshell.create: pane's shell already exited, not respawning — falling through to a fresh shell"
510            );
511            return None;
512        }
513        return Some(
514            (
515                StatusCode::INTERNAL_SERVER_ERROR,
516                Json(json!({ "error": format!("ptyshell.create: resync existing shell: {e}") })),
517            )
518                .into_response(),
519        );
520    }
521    // A shell that started or changed connection between the check above and
522    // the resync (a concurrent caller) is still not handed out on the wrong one.
523    if let Some(conflict) = connection_conflict(&block, connection) {
524        return Some(conflict);
525    }
526    answer_conpty_handshake_if_seen(state, id, baseline_len).await;
527    tracing::info!(block_id = %id, parent_id = %agent_block_id, "ptyshell.create: reused");
528    Some((StatusCode::OK, Json(PtyShellCreateResponse { shell_id: id.to_string() })).into_response())
529}
530
531/// Block meta on a `PtyShell` block: the srv boot id of the run that created it.
532pub(super) const META_KEY_PTYSHELL_BOOT_ID: &str = "ptyshell:bootid";
533
534/// Clear the agent block's shell pointer, if it still names `id` (see the
535/// exited-shell branch of `try_attach_to_existing_shell` for why it is
536/// compare-before-clear), so the next create claims a fresh shell.
537fn clear_shell_pointer_if(state: &AppState, agent_block_id: &str, id: &str) {
538    if let Ok(mut parent) = state.mstore.must_get::<crate::backend::obj::Block>(agent_block_id) {
539        if parent.meta.get(META_KEY_SHELL_SUBBLOCK_ID).and_then(|v| v.as_str()) == Some(id) {
540            parent.meta.insert(META_KEY_SHELL_SUBBLOCK_ID.to_string(), serde_json::Value::Null);
541            if state.mstore.update(&mut parent).is_ok() {
542                let update = crate::backend::obj::MuxObjUpdate {
543                    updatetype: "update".into(),
544                    otype: "block".into(),
545                    oid: agent_block_id.to_string(),
546                    obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
547                };
548                state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{agent_block_id}")));
549            }
550        }
551    }
552}
553
554/// 409 when the pane's running shell is on another connection than `wanted`.
555/// PtyShellStop releases the keyboard lock and leaves the shell running, so
556/// ending the shell is the way to switch.
557pub(super) fn connection_conflict(
558    shell_block: &crate::backend::obj::Block,
559    wanted: &str,
560) -> Option<axum::response::Response> {
561    let running_on = crate::backend::obj::meta_get_string(
562        &shell_block.meta,
563        blockcontroller::META_KEY_CONNECTION,
564        "local",
565    );
566    if crate::backend::remote::conn::same_connection(&running_on, wanted) {
567        // An SSH shell the user opened (not started for an agent) has an ssh
568        // that asks for passwords and host keys in its own terminal, which an
569        // agent reads and types into: it is never shared with an agent.
570        let ssh = matches!(
571            crate::backend::remote::ConnTarget::parse(&running_on),
572            Ok(crate::backend::remote::ConnTarget::Ssh(_))
573        );
574        let for_an_agent = !crate::backend::obj::meta_get_string(
575            &shell_block.meta,
576            crate::backend::remote::askpass::META_KEY_AGENT_BLOCK,
577            "",
578        )
579        .is_empty();
580        if ssh && !for_an_agent {
581            let error = format!(
582                "this pane's shell on '{running_on}' was opened by the user, and its ssh may ask for a \
583                 password in it, so an agent cannot share it. Use Shell instead, or ask the user to close it."
584            );
585            return Some((StatusCode::CONFLICT, Json(json!({ "error": error }))).into_response());
586        }
587        return None;
588    }
589    let error = format!(
590        "this pane's shell is running on '{running_on}', not '{wanted}'. \
591         End it with PtyShellInput(text: \"exit\\r\") and call PtyShell again, \
592         or call PtyShell with connection '{running_on}' to use it"
593    );
594    Some((StatusCode::CONFLICT, Json(json!({ "error": error }))).into_response())
595}
596
597#[cfg(test)]
598mod shared_shell_tests {
599    use super::connection_conflict;
600
601    fn shell(conn: &str, for_agent: bool) -> crate::backend::obj::Block {
602        let mut b = crate::backend::obj::Block::default();
603        b.meta.insert("connection".into(), serde_json::json!(conn));
604        if for_agent {
605            b.meta.insert(
606                crate::backend::remote::askpass::META_KEY_AGENT_BLOCK.into(),
607                serde_json::json!("agent-pane"),
608            );
609        }
610        b
611    }
612
613    /// The user's own SSH shell (its ssh prompts in its terminal) is never
614    /// handed to an agent; one started for an agent, or a local one, is.
615    #[test]
616    fn an_agent_never_shares_the_users_own_ssh_shell() {
617        assert!(connection_conflict(&shell("area54", false), "area54").is_some());
618        assert!(connection_conflict(&shell("area54", true), "area54").is_none());
619        assert!(connection_conflict(&shell("local", false), "local").is_none());
620        assert!(connection_conflict(&shell("wsl://Ubuntu", false), "wsl://Ubuntu").is_none());
621        assert!(connection_conflict(&shell("area54", true), "local").is_some(), "another connection");
622    }
623}