agentmux_srv/server/http_pty_shell.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! PTY shell HTTP handlers.
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/ptyshell/create` — attach to (creating if needed) "this
10/// pane's one shell."
11///
12/// Reuses the same sub-block a human's composer-drawer shell already uses
13/// or will use (`META_KEY_SHELL_SUBBLOCK_ID`), in either direction: if the
14/// human already opened the drawer, the agent joins that exact PTY — same
15/// scrollback, same live output. If nothing exists yet, this creates it
16/// (the same `view: "term", controller: "shell"` headless-sub-block shape
17/// `createsubblock`'s WS handler builds for `AgentShellSubblock.tsx`) and
18/// persists the pointer, so a drawer opened *afterward* attaches to what
19/// the agent already started instead of spawning a second, independent
20/// shell. Either way, `resync_controller` runs before returning — unlike
21/// the WS path (which waits for a frontend to mount a `term` view and call
22/// `ControllerResyncCommand`), the PTY is live the moment this call
23/// returns; a UI viewer for it is optional, not a prerequisite.
24pub(super) async fn handle_pty_shell_create(
25 State(state): State<AppState>,
26 Json(req): Json<PtyShellCreateRequest>,
27) -> impl IntoResponse {
28 // Where the shell runs, checked the way a pane's connection is: a refused
29 // one fails the call with its reason rather than opening a local shell.
30 use super::app_api::connections::{self, AgentTarget};
31 let target = match connections::for_agent(&state.broker, req.connection.as_deref()).await {
32 Ok(t) => t,
33 Err(e) => {
34 return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
35 }
36 };
37 let connection = match &target {
38 AgentTarget::Local => "local".to_string(),
39 AgentTarget::Wsl(d) => crate::backend::remote::ConnTarget::Wsl(d.clone()).name(),
40 AgentTarget::Ssh(dest) => dest.name(),
41 };
42 // An SSH host runs with the user's identity: their consent first, and the
43 // askpass helper must exist, since its prompts may never reach the
44 // terminal the agent reads (`remote::askpass`).
45 let mut ssh_agent = String::new();
46 if let AgentTarget::Ssh(_) = &target {
47 if crate::backend::remote::askpass::program().is_none() {
48 let e = "this AgentMux has no askpass helper (agentmux-bashwrap) to run an agent's SSH shell with";
49 return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
50 }
51 ssh_agent = match connections::verified_agent(&state, &req.agent_block_id, req.auth.as_ref()) {
52 Ok(a) => a,
53 Err(e) => return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response(),
54 };
55 // A running shell on another connection is refused before anything
56 // is asked: consent (and an "always") for a shell that then cannot
57 // start would be a grant the user gave for nothing.
58 if let Some(conflict) = running_shell_conflict(&state, &req.agent_block_id, &connection) {
59 return conflict;
60 }
61 if let Err(e) = connections::consent_for_ssh(&state, &req.agent_block_id, &ssh_agent, &connection, "an interactive shell (PtyShell)").await {
62 return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response();
63 }
64 }
65 let remote = !matches!(target, AgentTarget::Local);
66 let existing_id = state
67 .mstore
68 .get::<crate::backend::obj::Block>(&req.agent_block_id)
69 .ok()
70 .flatten()
71 .and_then(|b| {
72 b.meta
73 .get(META_KEY_SHELL_SUBBLOCK_ID)
74 .and_then(|v| v.as_str())
75 .map(str::to_string)
76 });
77
78 // Reuse path: the pointer exists and still resolves to a real block.
79 // Deliberately ignores req.cwd/rows/cols here — an already-running
80 // shell's cwd can't change after spawn, and geometry is a separate,
81 // legitimate post-hoc operation via PtyShellResize, not a create-time
82 // parameter for a process that already has a size.
83 if let Some(id) = existing_id {
84 if let Some(resp) = try_attach_to_existing_shell(&state, &req.agent_block_id, &id, &connection).await {
85 return resp;
86 }
87 // `None` here means either a stale pointer (the block was deleted
88 // some other way — rare) or, as of this PR, the pane's shell
89 // having already exited (the common case this PR fixes — see
90 // `try_attach_to_existing_shell`'s own doc comment). Either way,
91 // fall through to creating a fresh one, same as if none existed.
92 }
93
94 let child_id = uuid::Uuid::new_v4().to_string();
95
96 let mut meta = crate::backend::obj::MetaMapType::new();
97 // Which srv run created this shell: a block with no controller is one this
98 // run is still starting (a concurrent create), not one left from before a
99 // restart (`try_attach_to_existing_shell`).
100 meta.insert(META_KEY_PTYSHELL_BOOT_ID.to_string(), json!(&*state.boot_id));
101 // The agent's plain-`gh` guard, as its Shell and its own CLI get it: the
102 // agent types into this shell, so `gh` here must not act as the user.
103 let agent_env: std::collections::HashMap<String, String> = state
104 .mstore
105 .get::<crate::backend::obj::Block>(&req.agent_block_id)
106 .ok()
107 .flatten()
108 .map(|b| crate::backend::blockcontroller::cmd_env_of(&b.meta))
109 .unwrap_or_default();
110 meta.insert(
111 crate::backend::gh_guard::META_KEY_PTYSHELL_GH_CONFIG_DIR.to_string(),
112 json!(crate::backend::gh_guard::guard_dir_for(&agent_env)),
113 );
114 // And the publish guard's git hooks, so a push typed here is scanned too.
115 // Local shells only: the hooks directory is a host path, which a WSL distro
116 // or an SSH host can't run (pushes from there aren't guarded).
117 let local = matches!(target, AgentTarget::Local);
118 if let Some(hooks) = local.then(|| crate::backend::publish_guard::hooks_dir_for(&agent_env, |k| std::env::var(k).ok())).flatten() {
119 meta.insert(
120 crate::backend::publish_guard::META_KEY_PTYSHELL_GIT_HOOKS_PATH.to_string(),
121 json!(hooks.to_string_lossy()),
122 );
123 }
124 meta.insert("view".to_string(), json!("term"));
125 meta.insert(
126 blockcontroller::META_KEY_CONTROLLER.to_string(),
127 json!(blockcontroller::BLOCK_CONTROLLER_SHELL),
128 );
129 // An agent's shell doesn't follow the user's default-shell setting
130 // (term:localshellpath and term:localshellopts are for their terminals):
131 // pin it to the automatic shell with no extra arguments. Only macOS and
132 // Linux reach that branch; Windows runs cmd.exe below.
133 meta.insert(
134 blockcontroller::META_KEY_TERM_LOCAL_SHELL_PATH.to_string(),
135 json!(crate::backend::shells::automatic().display().to_string()),
136 );
137 meta.insert(blockcontroller::META_KEY_TERM_LOCAL_SHELL_OPTS.to_string(), json!([]));
138 // Force cmd.exe directly on Windows rather than the "shell" controller's
139 // default pwsh/powershell auto-detection (`backend::shells::automatic`,
140 // which tries pwsh first). Discovered live, not assumed: PowerShell's
141 // PSReadLine issues a cursor-position query (`ESC[6n`, Device Status
142 // Report) on interactive startup and blocks forever waiting for a
143 // `ESC[row;colR` reply — which only a real terminal emulator answering
144 // on the other end provides (xterm.js does, for the `term` widget /
145 // AgentShellSubblock's human-facing sessions). This facility has no
146 // terminal emulator reading the byte stream, only a raw capture, so
147 // every PtyShell() would otherwise hang before printing even its first
148 // prompt. cmd.exe has no such startup handshake. Non-Windows shells
149 // (bash/zsh via $SHELL) aren't affected — left as the "shell"
150 // controller's own default. This is a known-narrow mitigation, not a
151 // general fix: any OTHER program that queries terminal capabilities via
152 // escape sequences and blocks for a reply would hit the same class of
153 // issue — see docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md §5.
154 //
155 // A WSL shell is the distro's own login shell (`wsl.exe -d`), which has no
156 // such handshake, and `cmd.exe` would be looked up inside the distro.
157 #[cfg(windows)]
158 if !remote {
159 meta.insert(blockcontroller::META_KEY_CMD.to_string(), json!("cmd.exe"));
160 meta.insert("cmd:interactive".to_string(), json!(true));
161 }
162 if remote {
163 meta.insert(blockcontroller::META_KEY_CONNECTION.to_string(), json!(connection));
164 }
165 // If the caller didn't supply a cwd, fall back to the agent block's own
166 // cmd:cwd — the project directory the agent pane was launched with.
167 // Mirrors `handle_shell_create`'s identical fallback (Codex P1 on PR
168 // #3177): without this, the PTY spawns in agentmux-srv's own cwd
169 // (typically the portable runtime/ dir), not the agent's worktree.
170 //
171 // In WSL the cwd is a distro path, kept as given; no Windows fallback.
172 if let AgentTarget::Ssh(_) = &target {
173 let agent = ssh_agent.clone();
174 meta.insert(crate::backend::remote::askpass::META_KEY_AGENT_BLOCK.to_string(), json!(req.agent_block_id));
175 meta.insert(crate::backend::remote::askpass::META_KEY_AGENT.to_string(), json!(agent));
176 }
177 if remote {
178 if let Some(cwd) = req.cwd.as_deref().filter(|c| !c.trim().is_empty()) {
179 meta.insert(blockcontroller::META_KEY_CMD_CWD.to_string(), json!(cwd));
180 }
181 }
182 let effective_cwd = req.cwd.clone().or_else(|| {
183 state
184 .mstore
185 .get::<crate::backend::obj::Block>(&req.agent_block_id)
186 .ok()
187 .flatten()
188 .and_then(|b| {
189 let cwd = crate::backend::obj::meta_get_string(&b.meta, "cmd:cwd", "");
190 if cwd.is_empty() { None } else { Some(cwd) }
191 })
192 });
193 let effective_cwd = effective_cwd.filter(|_| !remote);
194 if let Some(cwd) = &effective_cwd {
195 // Same MSYS→native normalization createsubblock's WS handler applies —
196 // the PTY spawn path reads cmd:cwd raw with no conversion, so a
197 // Git-Bash-style path would otherwise fail with os error 267 on
198 // Windows.
199 match crate::backend::base::normalize_working_dir(cwd)
200 .filter(|p| std::path::Path::new(p).is_absolute())
201 {
202 Some(norm) => {
203 meta.insert(blockcontroller::META_KEY_CMD_CWD.to_string(), json!(norm));
204 }
205 None => {
206 tracing::warn!(
207 raw_cwd = %cwd,
208 "ptyshell.create: invalid or non-absolute cwd, dropping (no cwd)"
209 );
210 }
211 }
212 }
213
214 // Initial PTY geometry, if the caller specified one — otherwise
215 // `resync_controller`'s own fallback (25x200) applies. Both `rows`/`cols`
216 // being absent is the common case; either one alone still takes effect
217 // (see `pty_size_from_rt_opts`'s per-field guards).
218 let runtimeopts = if req.rows.is_none() && req.cols.is_none() {
219 None
220 } else {
221 Some(crate::backend::obj::RuntimeOpts {
222 termsize: crate::backend::obj::TermSize {
223 rows: req.rows.unwrap_or(25) as i64,
224 cols: req.cols.unwrap_or(200) as i64,
225 },
226 ..Default::default()
227 })
228 };
229 let rt_opts_json = runtimeopts.as_ref().map(|rt| {
230 json!({ "termsize": { "rows": rt.termsize.rows, "cols": rt.termsize.cols } })
231 });
232
233 let mut block = crate::backend::obj::Block {
234 oid: child_id.clone(),
235 parentoref: format!("block:{}", req.agent_block_id),
236 meta,
237 runtimeopts,
238 ..Default::default()
239 };
240
241 // Atomic claim-or-lose (Codex P2 on PR #3194): without this, two
242 // concurrent `PtyShell` calls that both observe an unset
243 // `term:shellsubblockid` (the check at the top of this function is
244 // NOT authoritative — it's a plain read, before this point) would each
245 // insert their own block and race to overwrite the other's pointer
246 // write, leaving one controller registered but orphaned from the
247 // pointer that's supposed to track it, and the two callers ending up
248 // attached to DIFFERENT shells despite both believing they're on "the
249 // pane's one shell." `with_tx` holds the store's single connection
250 // lock for its whole closure, so re-checking the pointer INSIDE it,
251 // atomically with the insert+link+set that follows, serializes any
252 // concurrent `PtyShell` call against this same agent_block_id.
253 //
254 // Does NOT close the equivalent race against the FRONTEND's own
255 // `createsubblock` path (`AgentShellSubblock.tsx`'s "no
256 // existingSubBlockId yet" branch): that's a separate, non-transactional
257 // multi-step RPC sequence (create, then a follow-up `SetMetaCommand`)
258 // this backend call has no visibility into or ability to serialize
259 // against. A human opening the drawer for the very first time on a
260 // pane in the same narrow window as an agent's first `PtyShell` call
261 // on that pane can still each end up with their own shell — a real,
262 // documented, accepted gap (see the spec's §10 for the full
263 // reasoning), not silently ignored, just out of scope for what a
264 // single backend transaction can enforce unilaterally.
265 let claim = state.mstore.with_tx(|tx| {
266 let mut parent = tx.must_get::<crate::backend::obj::Block>(&req.agent_block_id)?;
267 if let Some(winner_id) = parent
268 .meta
269 .get(META_KEY_SHELL_SUBBLOCK_ID)
270 .and_then(|v| v.as_str())
271 {
272 return Ok(Some(winner_id.to_string()));
273 }
274 tx.insert(&mut block)?;
275 parent.subblockids.get_or_insert_with(Vec::new).push(child_id.clone());
276 parent
277 .meta
278 .insert(META_KEY_SHELL_SUBBLOCK_ID.to_string(), json!(child_id));
279 tx.update(&mut parent)?;
280 Ok(None)
281 });
282
283 let (shell_id, block) = match claim {
284 // We lost the race — someone else's transaction committed the
285 // pointer first. Pivot to reusing THEIRS rather than proceeding
286 // with the block we prepared but never actually inserted.
287 Ok(Some(winner_id)) => {
288 if let Some(resp) = try_attach_to_existing_shell(&state, &req.agent_block_id, &winner_id, &connection).await {
289 return resp;
290 }
291 // Winner's block vanished between the transaction and now, OR
292 // (as of this PR) resolved to a shell that had already exited.
293 // Both stay exceedingly unlikely to land HERE specifically:
294 // `try_attach_to_existing_shell` now clears the parent's
295 // pointer itself the moment it finds an exited shell (see its
296 // own comment on the `RESYNC_ERR_ALREADY_EXITED` branch), so
297 // the common "human typed `exit`, then something calls
298 // PtyShellCreate again" case is intercepted one level up — the
299 // CLAIM transaction above reads the pointer as already-cleared
300 // and takes the plain `Ok(None)` fresh-insert path, never
301 // reaching this branch at all. Getting here for an exited
302 // shell specifically requires a genuine concurrent-call race
303 // (two callers both observe the stale pointer before either
304 // one's clear commits) — same rarity class as the pre-existing
305 // deleted-out-from-under-us case this comment originally
306 // described, not the common case. `block` above was only ever
307 // prepared in memory: the
308 // `with_tx` closure's `tx.insert` never ran for it (that only
309 // happens in the `Ok(None)` arm, when WE win the claim), so it
310 // has no backing row. Falling through with it as-is would send
311 // `resync_controller` a phantom block and return a `shell_id`
312 // with nothing in the store behind it — every later
313 // PtyShellInput/Read/Status/Stop call against it would then
314 // fail ownership/lookup checks (ReAgent P2 on PR #3194).
315 //
316 // Actually create it for real instead, mirroring what the
317 // `Ok(None)` arm does inside its transaction — just as a plain
318 // sequence, since we're no longer inside that transaction.
319 let insert_result = (|| -> Result<(), crate::backend::storage::error::StoreError> {
320 state.mstore.insert(&mut block)?;
321 let mut parent = state
322 .mstore
323 .must_get::<crate::backend::obj::Block>(&req.agent_block_id)?;
324 parent
325 .subblockids
326 .get_or_insert_with(Vec::new)
327 .push(child_id.clone());
328 parent
329 .meta
330 .insert(META_KEY_SHELL_SUBBLOCK_ID.to_string(), json!(child_id));
331 state.mstore.update(&mut parent)?;
332 Ok(())
333 })();
334 if let Err(e) = insert_result {
335 return (
336 StatusCode::INTERNAL_SERVER_ERROR,
337 Json(json!({ "error": format!("ptyshell.create: fallback insert: {e}") })),
338 )
339 .into_response();
340 }
341 if let Ok(Some(parent)) = state
342 .mstore
343 .get::<crate::backend::obj::Block>(&req.agent_block_id)
344 {
345 let update = crate::backend::obj::MuxObjUpdate {
346 updatetype: "update".into(),
347 otype: "block".into(),
348 oid: req.agent_block_id.clone(),
349 obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
350 };
351 state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
352 }
353 (child_id.clone(), block)
354 }
355 Ok(None) => {
356 // We won the claim and the parent's meta changed (inside the
357 // transaction, via a raw `tx.update`, which — unlike
358 // `broadcast_meta_update` — does NOT itself notify any
359 // subscribed frontend). Broadcast now so a drawer already open
360 // (or opened moments later) actually sees the pointer reactively
361 // instead of only on its next unrelated refetch.
362 if let Ok(Some(parent)) = state.mstore.get::<crate::backend::obj::Block>(&req.agent_block_id) {
363 let update = crate::backend::obj::MuxObjUpdate {
364 updatetype: "update".into(),
365 otype: "block".into(),
366 oid: req.agent_block_id.clone(),
367 obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
368 };
369 state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
370 }
371 (child_id.clone(), block)
372 }
373 Err(e) => {
374 return (
375 StatusCode::INTERNAL_SERVER_ERROR,
376 Json(json!({ "error": format!("ptyshell.create: claim: {e}") })),
377 )
378 .into_response();
379 }
380 };
381 let child_id = shell_id;
382
383 let registry = state.mstore.shared_agent_registry();
384 if let Err(e) = blockcontroller::resync_controller(
385 &block,
386 "ptyshell", // headless — no real tab; only used for tracing/scoping.
387 rt_opts_json,
388 false,
389 true, // respawn_if_done — irrelevant here, this block is always freshly inserted (STATUS_INIT), never STATUS_DONE
390 Some(Arc::clone(&state.broker)),
391 Some(Arc::clone(&state.event_bus)),
392 Some(Arc::clone(&state.mstore)),
393 Some(Arc::clone(&state.filestore)),
394 Some(Arc::clone(&state.id_store)),
395 Some(Arc::clone(&state.identity_store)),
396 registry,
397 state.boot_id.clone(),
398 &state.auth_key,
399 Some(Arc::clone(&state.config_watcher)),
400 ) {
401 // Roll back — the block was already inserted and linked into the
402 // parent's subblockids above (Codex P2 on PR #3177): without this,
403 // a failed spawn leaves a stale block + parent link + (possibly)
404 // registered controller behind that the caller has no way to clean
405 // up, since it never received a `shell_id` to call PtyShellStop with.
406 crate::sagas::agent_teardown::discard(&child_id);
407 let _ = state.mstore.delete::<crate::backend::obj::Block>(&child_id);
408 if let Ok(mut parent) = state
409 .mstore
410 .must_get::<crate::backend::obj::Block>(&req.agent_block_id)
411 {
412 let mut changed = false;
413 if let Some(ids) = parent.subblockids.as_mut() {
414 let before = ids.len();
415 ids.retain(|id| id != &child_id);
416 changed |= ids.len() != before;
417 }
418 // The pointer was already persisted before `resync_controller`
419 // ran — either inside the atomic claim's own transaction, or by
420 // the "winner vanished" fallback above — so unlinking
421 // `subblockids` alone isn't enough: without also clearing it
422 // here, `term:shellsubblockid` is left pointing at a block that
423 // was just deleted, and a human opening the composer drawer in
424 // this exact window (`agent-view.tsx`'s
425 // `existingSubBlockId={block()?.meta?.["term:shellsubblockid"]}`)
426 // attaches to nothing instead of getting a working shell
427 // (ReAgent P1, round 3 on PR #3194).
428 if parent.meta.get(META_KEY_SHELL_SUBBLOCK_ID).and_then(|v| v.as_str()) == Some(child_id.as_str())
429 {
430 parent.meta.remove(META_KEY_SHELL_SUBBLOCK_ID);
431 changed = true;
432 }
433 if changed {
434 let _ = state.mstore.update(&mut parent);
435 let update = crate::backend::obj::MuxObjUpdate {
436 updatetype: "update".into(),
437 otype: "block".into(),
438 oid: req.agent_block_id.clone(),
439 obj: Some(crate::backend::obj::mux_obj_to_value(&parent)),
440 };
441 state.event_bus.broadcast_event(&crate::backend::eventbus::EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&update, format!("block:{}", req.agent_block_id)));
442 }
443 }
444 return (
445 StatusCode::INTERNAL_SERVER_ERROR,
446 Json(json!({ "error": format!("ptyshell.create: spawn: {e}") })),
447 )
448 .into_response();
449 }
450
451 // baseline_len=0: a genuinely new block has no prior "term" file content
452 // to accidentally match against (see the reuse path's identical
453 // baseline logic above for why this matters at all).
454 answer_conpty_handshake_if_seen(&state, &child_id, 0).await;
455
456 // NOTE: the shellsubblockid pointer is already persisted — the atomic
457 // claim transaction above sets it on the parent in the same
458 // transaction as the insert, which is also strictly more correct than
459 // a separate post-hoc write here would be (no window where the block
460 // exists but isn't yet pointed to).
461 tracing::info!(block_id = %child_id, parent_id = %req.agent_block_id, "ptyshell.create: fresh");
462 (StatusCode::OK, Json(PtyShellCreateResponse { shell_id: child_id })).into_response()
463}
464
465/// Answer Windows ConPTY's own startup handshake, if seen, before the
466/// caller proceeds. Discovered live (not assumed): the moment a Windows
467/// ConPTY is created, it queries the cursor position (`ESC[6n`, Device
468/// Status Report) and blocks its own internal state — and therefore every
469/// later write — until it sees a `ESC[<row>;<col>R` reply. A human-facing
470/// `term` pane gets this for free because xterm.js, running in the
471/// browser, answers real cursor queries as part of being an actual
472/// terminal emulator; this headless facility has nothing playing that
473/// role, so without this the PTY would sit permanently stuck before
474/// printing even its first prompt — confirmed by a live round trip that
475/// hung indefinitely until this was added. `ESC[1;1R` (cursor at row 1,
476/// col 1) is a plausible-enough fixed answer: nothing here has rendered
477/// anything yet, so "top-left" is as true as any other coordinate, and
478/// ConPTY only wants to reconcile its internal buffer, not validate the
479/// value against anything a person would see.
480///
481/// `baseline_len` MUST be the "term" file's byte length taken immediately
482/// before `resync_controller` ran (0 for a genuinely new block) — see the
483/// call sites. Only bytes appended AFTER that offset are ever inspected.
484/// **Not** safe to scan the whole file regardless of reuse/fresh (an
485/// earlier version of this function did exactly that): the "term" file is
486/// append-only for the block's entire lifetime
487/// (`handle_append_block_file`'s `FILE_OP_APPEND`), so a long-running
488/// reused shell's history almost always still contains its own original,
489/// long-since-answered `ESC[6n` from whenever it first started — matching
490/// against that stale byte sequence would inject an unsolicited
491/// `ESC[1;1R` into whatever a human is doing RIGHT NOW on that shell
492/// (Codex P1 on PR #3194, confirmed live before this fix: `create` on an
493/// already-running reused shell always found the old query and misfired).
494/// The baseline makes every case correct uniformly: nothing new appears
495/// (the common reuse case) → correctly finds nothing; `resync_controller`
496/// had to respawn a dead process → correctly sees only ITS fresh query,
497/// never anything from the block's former life.
498#[cfg_attr(not(windows), allow(unused_variables))]
499pub(super) async fn answer_conpty_handshake_if_seen(state: &AppState, block_id: &str, baseline_len: usize) {
500 #[cfg(windows)]
501 {
502 let mut answered = false;
503 for _ in 0..20 {
504 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
505 let saw_dsr = state
506 .filestore
507 .read_file(block_id, "term")
508 .ok()
509 .flatten()
510 .map(|bytes| {
511 let new_bytes = bytes.get(baseline_len..).unwrap_or(&[]);
512 new_bytes.windows(4).any(|w| w == b"\x1b[6n")
513 })
514 .unwrap_or(false);
515 if saw_dsr {
516 let _ = blockcontroller::send_input(
517 block_id,
518 blockcontroller::BlockInputUnion::data(b"\x1b[1;1R".to_vec()),
519 None,
520 );
521 answered = true;
522 break;
523 }
524 }
525 tracing::info!(block_id = %block_id, answered_dsr = answered, "ptyshell: ConPTY handshake check");
526 }
527}
528
529/// `POST /api/v1/ptyshell/input` — write raw text to the PTY, as if typed.
530/// Unlike `/api/v1/shell/input`, no newline is appended: a real terminal
531/// doesn't add one either, and PTY programs (readline, Sharprompt) expect
532/// the caller to send exactly what a keyboard would (e.g. `"y\n"` to answer
533/// a prompt and press Enter, or `"\x03"` for Ctrl+C — the OS PTY layer
534/// translates that raw byte into a real interrupt delivered to the
535/// foreground process, same as a human pressing Ctrl+C in a real terminal,
536/// without ending the shell).
537///
538/// There is deliberately no separate "send a signal" endpoint: an earlier
539/// version of this API had one (`PtyShellSignal`, `BlockInputUnion::signal`),
540/// removed after Codex correctly flagged (PR #3177) that
541/// `ShellController`'s input loop treats ANY signal as "close the PTY" —
542/// `if input.sig_name.is_some() { break; }` in
543/// `blockcontroller/shell/lifecycle.rs` — so it terminated the whole shell
544/// instead of interrupting just the foreground command, the opposite of
545/// what it advertised. Real signal delivery through a PTY is the raw
546/// control byte, handled here, not a separate primitive.
547pub(super) async fn handle_pty_shell_input(
548 State(state): State<AppState>,
549 Json(req): Json<PtyShellInputRequest>,
550) -> impl IntoResponse {
551 if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
552 return (
553 StatusCode::OK,
554 Json(PtyShellInputResponse {
555 written: false,
556 error: Some("not a shell belonging to this agent's own pane".to_string()),
557 }),
558 );
559 }
560 // Lock BEFORE writing, not after (ReAgent P1 on PR #3194): the
561 // previous ordering left `term:agentlockuntil` unset for the entire
562 // duration of `send_input` — the exact window `controllerinput`'s own
563 // lock check (added for Codex's earlier P1 on this same PR) needs the
564 // lock to already exist to catch anything. Locking first doesn't make
565 // the race provably impossible (a human keystroke processed in the
566 // sliver of time before this write actually commits could still slip
567 // through — true elimination would need a real mutex shared between
568 // this HTTP path and the WS `controllerinput` path, disproportionate
569 // machinery for a UX-level collision guard, not a hard security
570 // boundary), but it shrinks the window from "all of send_input's own
571 // duration plus the lock write's" down to just the lock write's own
572 // commit latency — the actual, meaningful fix here.
573 lock_shell_for_agent(&state, &req.shell_id);
574 let result = blockcontroller::send_input(
575 &req.shell_id,
576 blockcontroller::BlockInputUnion::data(req.text.into_bytes()),
577 None,
578 );
579 match result {
580 Ok(()) => (StatusCode::OK, Json(PtyShellInputResponse { written: true, error: None })),
581 Err(e) => (StatusCode::OK, Json(PtyShellInputResponse { written: false, error: Some(e) })),
582 }
583}
584
585/// Lock `shell_id` out from human keyboard input for `AGENT_LOCK_WINDOW_MS`
586/// from now — called BEFORE every agent write (input/resize), not after
587/// (see the call sites' own comment for why the ordering matters).
588/// Best-effort: a failure to write the lock meta just means the human's
589/// input isn't blocked this time, not that the agent's write itself failed.
590pub(super) fn lock_shell_for_agent(state: &AppState, shell_id: &str) {
591 let until_ms = agentmux_common::time::now_ms() + AGENT_LOCK_WINDOW_MS;
592
593 // In-memory first, and unconditionally: this is what `controllerinput`
594 // actually enforces against (see `blockcontroller::agent_lock`'s module
595 // doc for why the enforcement path can't afford a `Store` read). It
596 // cannot fail and costs a hash insert, so the lock is live before the
597 // persisted/broadcast half is even attempted — which also shrinks the
598 // pre-existing "human keystroke in the sliver before the write commits"
599 // window described above to the width of a mutex acquire.
600 blockcontroller::agent_lock::lock_until(shell_id, until_ms);
601
602 // Then the meta write, which drives the frontend's own badge + gate
603 // (`AgentShellSubblock.tsx`). Best-effort, exactly as before: a failure
604 // here costs the human the "Agent is using this shell" indicator, not
605 // the enforcement itself.
606 let mut meta = crate::backend::obj::MetaMapType::new();
607 meta.insert(META_KEY_AGENT_LOCK_UNTIL.to_string(), json!(until_ms));
608 if let Err(e) = broadcast_meta_update(state, shell_id, &meta) {
609 tracing::warn!(shell_id = %shell_id, error = %e, "ptyshell: failed to write agent lock");
610 }
611}
612
613/// `POST /api/v1/ptyshell/resize` — resize the PTY. Some TUIs render
614/// differently or wrap badly at the fallback geometry (25x200).
615pub(super) async fn handle_pty_shell_resize(
616 State(state): State<AppState>,
617 Json(req): Json<PtyShellResizeRequest>,
618) -> impl IntoResponse {
619 if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
620 return (
621 StatusCode::OK,
622 Json(PtyShellResizeResponse {
623 resized: false,
624 error: Some("not a shell belonging to this agent's own pane".to_string()),
625 }),
626 );
627 }
628 // Lock BEFORE writing — see the identical comment in
629 // `handle_pty_shell_input` for why the ordering matters.
630 lock_shell_for_agent(&state, &req.shell_id);
631 let result = blockcontroller::send_input(
632 &req.shell_id,
633 blockcontroller::BlockInputUnion::resize(crate::backend::obj::TermSize {
634 rows: req.rows as i64,
635 cols: req.cols as i64,
636 }),
637 None,
638 );
639 match result {
640 Ok(()) => (StatusCode::OK, Json(PtyShellResizeResponse { resized: true, error: None })),
641 Err(e) => (StatusCode::OK, Json(PtyShellResizeResponse { resized: false, error: Some(e) })),
642 }
643}
644
645/// `POST /api/v1/ptyshell/read` — read back the shell's raw output tail.
646///
647/// Reads the block's `term` file straight from `FileStore` — the same file
648/// `blockcontroller::shell::lifecycle`'s real PTY read loop write-throughs
649/// to (`handle_append_block_file(..., "term", ..., filestore, ...)`,
650/// `SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md` §2.1). Deliberately
651/// NOT the optimized `blockfile:read_range` path (byte-offset index,
652/// cross-channel global-transcript fallback, and a different filename —
653/// "output" — used for agent CLI transcripts, a different content stream
654/// entirely), which solves problems this endpoint doesn't have: a PTY shell
655/// created through this API is always local and short-lived, so a plain
656/// whole-file read + in-memory tail is simpler and correct. This is a raw
657/// line log, not a rendered-screen snapshot — see the type's own doc
658/// comment in `api_types.rs`.
659pub(super) async fn handle_pty_shell_read(
660 State(state): State<AppState>,
661 Json(req): Json<PtyShellReadRequest>,
662) -> impl IntoResponse {
663 if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
664 return (
665 StatusCode::OK,
666 Json(PtyShellReadResponse { content: String::new(), truncated: false }),
667 )
668 .into_response();
669 }
670 let content = match state.filestore.read_file(&req.shell_id, "term") {
671 Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).to_string(),
672 Ok(None) => String::new(),
673 Err(e) => {
674 return (
675 StatusCode::INTERNAL_SERVER_ERROR,
676 Json(json!({ "error": format!("ptyshell.read: {e}") })),
677 )
678 .into_response();
679 }
680 };
681 let tail_lines = req.tail_lines.unwrap_or(200).max(1) as usize;
682 let lines: Vec<&str> = content.lines().collect();
683 let start = lines.len().saturating_sub(tail_lines);
684 let truncated = start > 0;
685 let tail = lines[start..].join("\n");
686 (StatusCode::OK, Json(PtyShellReadResponse { content: tail, truncated })).into_response()
687}
688
689/// `POST /api/v1/ptyshell/status` — query whether the shell is still running.
690pub(super) async fn handle_pty_shell_status(
691 State(state): State<AppState>,
692 Json(req): Json<PtyShellStatusRequest>,
693) -> impl IntoResponse {
694 if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
695 return (StatusCode::OK, Json(PtyShellStatusResponse { running: false, exit_code: None }));
696 }
697 match blockcontroller::get_block_controller_status(&req.shell_id) {
698 Some(status) => {
699 let running = status.shellprocstatus == blockcontroller::STATUS_RUNNING;
700 let exit_code = (status.shellprocstatus == blockcontroller::STATUS_DONE)
701 .then_some(status.shellprocexitcode);
702 (StatusCode::OK, Json(PtyShellStatusResponse { running, exit_code }))
703 }
704 // No controller registered — unknown id or already torn down via
705 // ptyshell/stop. Same "plain not-running answer, not an error"
706 // contract ShellStatus already documents.
707 None => (StatusCode::OK, Json(PtyShellStatusResponse { running: false, exit_code: None })),
708 }
709}
710
711/// `POST /api/v1/ptyshell/stop` — release the agent's lock immediately,
712/// without waiting for `AGENT_LOCK_WINDOW_MS` to lapse on its own.
713///
714/// Does not touch the process or the block at all (see
715/// `PtyShellStopResponse`'s doc comment in `api_types.rs` for why: this
716/// shell is a shared, pane-scoped resource now, not a private one this API
717/// owns outright).
718pub(super) async fn handle_pty_shell_stop(
719 State(state): State<AppState>,
720 Json(req): Json<PtyShellStopRequest>,
721) -> impl IntoResponse {
722 if !is_owned_by_agent(&state.mstore, &req.shell_id, &req.agent_block_id) {
723 return (StatusCode::OK, Json(PtyShellStopResponse { released: false }));
724 }
725 // Released in memory first — that's the copy `controllerinput` enforces
726 // against, so the human's keyboard is live again the instant this
727 // returns, not once the meta write commits and the frontend hears about
728 // it. The return value is the authoritative "was a lease actually held",
729 // for the same reason.
730 let was_locked = blockcontroller::agent_lock::release(&req.shell_id);
731
732 let mut meta = crate::backend::obj::MetaMapType::new();
733 meta.insert(META_KEY_AGENT_LOCK_UNTIL.to_string(), serde_json::Value::Null);
734 let released = broadcast_meta_update(&state, &req.shell_id, &meta).is_ok() && was_locked;
735
736 tracing::info!(block_id = %req.shell_id, released, "ptyshell.stop: released lock");
737 (StatusCode::OK, Json(PtyShellStopResponse { released }))
738}
739
740/// The 409 for a pane whose agent shell is running (or starting, created by
741/// this run) on another connection than `connection`, checked without
742/// touching the shell; the same cases `try_attach_to_existing_shell` refuses.
743fn running_shell_conflict(state: &AppState, agent_block_id: &str, connection: &str) -> Option<axum::response::Response> {
744 let shell_id = state
745 .mstore
746 .get::<crate::backend::obj::Block>(agent_block_id)
747 .ok()
748 .flatten()
749 .and_then(|b| b.meta.get(META_KEY_SHELL_SUBBLOCK_ID).and_then(|v| v.as_str()).map(str::to_string))?;
750 let shell = state.mstore.get::<crate::backend::obj::Block>(&shell_id).ok().flatten()?;
751 // Running, or created by this srv run and still starting (a concurrent
752 // create won the claim): either way `try_attach_to_existing_shell` would
753 // refuse it, so it is refused here too, before anything is asked.
754 let live = match blockcontroller::get_block_controller_status(&shell_id) {
755 Some(s) => s.shellprocstatus == blockcontroller::STATUS_RUNNING,
756 None => {
757 crate::backend::obj::meta_get_string(&shell.meta, super::http_shell::META_KEY_PTYSHELL_BOOT_ID, "")
758 == *state.boot_id
759 }
760 };
761 if !live {
762 return None;
763 }
764 super::http_shell::connection_conflict(&shell, connection)
765}