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}