agentmux_srv/server/agent_handlers/input.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::blockcontroller;
7use crate::backend::obj::Block;
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10 AttachmentRef, CommandAgentInputData, CommandAgentStopData, CommandSubprocessSpawnData, COMMAND_AGENT_INPUT,
11 COMMAND_AGENT_STOP, COMMAND_SUBPROCESS_SPAWN,
12};
13
14use super::super::AppState;
15
16/// Heal a container agent's argv IN PLACE when — and only when — it still
17/// carries the persistent controller's flags.
18///
19/// A container agent runs one `docker exec` per turn: `container_spawn.rs`
20/// writes the raw message, closes stdin, and reads until EOF. There is no
21/// long-lived stdin and no control channel. The PERSISTENT argv is wrong for
22/// that in three separate ways, and it is what stale blocks carry:
23///
24/// * `--input-format stream-json` — makes the CLI parse every stdin line as
25/// a JSON envelope, so it meets the startup markdown and dies with
26/// `JSON Parse error: Unrecognized token '#'`. This is the crash that kept
27/// container agents from EVER starting (verified live, 2026-08-31).
28/// * missing `-p` — the one-shot print flag. Without it the CLI is not in
29/// the mode this path drives at all.
30/// * `--permission-prompt-tool stdio` (+ a non-bypass `--permission-mode`) —
31/// routes tool permissions through the control protocol, which only the
32/// persistent controller speaks. A container turn has nothing to answer
33/// `can_use_tool`, so it rejects or hangs on the first permission check.
34///
35/// An earlier cut of this deleted only `--input-format` and left the other two
36/// (codex P1 on PR #2867) — that unblocks the immediate crash while leaving the
37/// pane in the wrong CLI mode, which is a worse failure because it looks like
38/// it works.
39///
40/// The cut after THAT rebuilt the argv wholesale from `launch_args`, which was
41/// worse again (reagent P1 on the same PR): this runs on EVERY container turn,
42/// not once per stale block, so a rebuild also threw away everything
43/// `agent-model.ts` legitimately appends after the base args —
44/// `agent.provider_flags` (user-configurable) and `--fork-session` (gated on
45/// `providerId === "claude"`, not on `agentMode`, so container agents reach it).
46/// It would have permanently broken both for container agents, on blocks whose
47/// argv this fix had already assembled correctly.
48///
49/// So: subtract, don't rebuild. Remove the flags the PERSISTENT argv carries
50/// that the one-shot argv does not, restore any one-shot flag that's missing,
51/// and leave every other token — the user's `--model`/`--effort`, their
52/// `provider_flags`, `--fork-session` — exactly where it was. Which flags are
53/// "persistent-only" is derived from the provider catalog rather than hardcoded,
54/// so a future flag moving between the two lists doesn't silently strand this.
55///
56/// An argv carrying NO persistent-only flag is returned untouched: it was built
57/// by the fixed `selectLaunchArgs` path and there is nothing to heal.
58///
59/// The root cause is fixed at the source in `launch-args.ts`; this is the
60/// self-heal for blocks ALREADY persisted with the bad argv, since `cmd:args`
61/// lives in block meta and would otherwise stay wrong forever. Applied at the
62/// point of use so a block that never re-runs `resync_controller` can't bypass
63/// it.
64pub(crate) fn container_argv(argv: Vec<String>, provider_id: &str) -> Vec<String> {
65 let Some(provider) = crate::backend::providers::get_provider(provider_id) else {
66 // Unknown provider — no catalog to diff against, so remove the one flag
67 // that is outright fatal rather than guessing at the rest.
68 return strip_flag_with_value(argv, "--input-format");
69 };
70 let Some(persistent) = provider.persistent_launch_args else {
71 // Subprocess-shaped provider: its only argv IS the one-shot argv, so a
72 // container block could never have been given persistent flags.
73 return argv;
74 };
75
76 let one_shot = flags_with_arity(provider.launch_args);
77 let persistent_only: Vec<(&str, bool)> = flags_with_arity(persistent)
78 .into_iter()
79 .filter(|(flag, _)| !one_shot.iter().any(|(f, _)| f == flag))
80 .collect();
81
82 // Already one-shot-shaped — the frontend assembled this argv, hands off.
83 if !argv
84 .iter()
85 .any(|a| persistent_only.iter().any(|(f, _)| f == a))
86 {
87 return argv;
88 }
89
90 let mut out = argv;
91 for (flag, takes_value) in &persistent_only {
92 out = if *takes_value {
93 strip_flag_with_value(out, flag)
94 } else {
95 out.into_iter().filter(|a| a != flag).collect()
96 };
97 }
98
99 // Restore the one-shot flags the persistent argv never had (`-p` above all).
100 // Prepended so the provider's own baseline keeps its leading position.
101 let mut restored: Vec<String> = Vec::new();
102 for (flag, takes_value) in &one_shot {
103 if out.iter().any(|a| a == flag) {
104 continue;
105 }
106 restored.push(flag.to_string());
107 if *takes_value {
108 if let Some(pos) = provider.launch_args.iter().position(|a| a == flag) {
109 if let Some(v) = provider.launch_args.get(pos + 1) {
110 restored.push(v.to_string());
111 }
112 }
113 }
114 }
115 restored.extend(out);
116 restored
117}
118
119/// Split an args list into `(flag, takes_a_value)` pairs.
120///
121/// Arity is read off the list itself — a flag whose next token is not another
122/// flag takes a value. That's what lets `container_argv` strip
123/// `--permission-prompt-tool stdio` (two tokens) and `--verbose` (one)
124/// correctly without a hardcoded table of every provider's flag shapes.
125fn flags_with_arity(args: &'static [&'static str]) -> Vec<(&'static str, bool)> {
126 let mut out = Vec::new();
127 for (i, tok) in args.iter().enumerate() {
128 if !tok.starts_with('-') {
129 continue;
130 }
131 let takes_value = args.get(i + 1).is_some_and(|next| !next.starts_with('-'));
132 out.push((*tok, takes_value));
133 }
134 out
135}
136
137/// Remove `flag` and the value token following it, everywhere it appears.
138/// Tolerates a trailing `flag` with no value rather than panicking.
139fn strip_flag_with_value(args: Vec<String>, flag: &str) -> Vec<String> {
140 let mut out = Vec::with_capacity(args.len());
141 let mut it = args.into_iter();
142 while let Some(a) = it.next() {
143 if a == flag {
144 let _ = it.next();
145 continue;
146 }
147 out.push(a);
148 }
149 out
150}
151
152/// Per-agent git commit identity env vars for a spawned agent process
153/// (2026-08-22, docs/retro/retro-shared-git-identity-committer-misattribution-2026-08-22.md).
154///
155/// Without this, `git commit` falls through to whatever user.name/
156/// user.email happens to be sitting in a shared multi-agent host's
157/// `~/.gitconfig` -- one agent's real identity, silently baked into every
158/// OTHER agent's commits too. `agentmux-cloud`'s review-notification
159/// consumer resolves "who committed this" via GitHub's own commit->account
160/// auto-linking (`commit.author.login`), not the raw git commit metadata
161/// directly -- so whichever agent's *real, GitHub-verified* email is
162/// sitting in the shared config silently receives every other agent's
163/// committer notifications too (confirmed live: every commit across 9+
164/// PRs from 4 different agents on one host resolved to a single agent's
165/// account this way).
166///
167/// `GIT_AUTHOR_*`/`GIT_COMMITTER_*` env vars take precedence over
168/// config-file `user.name`/`user.email` at commit time (git's own
169/// behavior) -- scoped to just the spawned process tree, same pattern as
170/// `AGENTMUX_AGENT_ID`. The email intentionally uses a non-existent
171/// `.local` domain: GitHub can't link it to any real account, so an agent
172/// with no dedicated PAT registered (the common case -- see this repo's
173/// `CLAUDE.md`, "Which GitHub account am I acting as?") simply drops out
174/// of `commit.author.login` lookups entirely instead of resolving to
175/// someone else's real, verified identity.
176fn git_identity_env_vars(agent_id: &str) -> [(&'static str, String); 4] {
177 let git_email = format!("{}@agentmux.local", agent_id.to_lowercase());
178 [
179 ("GIT_AUTHOR_NAME", agent_id.to_string()),
180 ("GIT_COMMITTER_NAME", agent_id.to_string()),
181 ("GIT_AUTHOR_EMAIL", git_email.clone()),
182 ("GIT_COMMITTER_EMAIL", git_email),
183 ]
184}
185
186/// Identity M0 — `SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
187/// §9.1: make the display name and the slug two explicit variables, present
188/// on every launch path.
189///
190/// **Why.** `AGENTMUX_AGENT_ID` means two different things depending on who
191/// spawned the agent (spec §0.2): the frontend's `launchAgentDefinition`
192/// writes the definition *slug* into it (`agent-model.ts`), while this
193/// server path's fallback wrote the *display name*
194/// (`block.meta["agentName"]`) until `carry_agent_id_env` (2026-10-12), which
195/// writes the slug too when the row has one. Nothing downstream could tell
196/// which it got. The frontend path already sets
197/// `AGENTMUX_AGENT_DISPLAY` and `AGENTMUX_AGENT_SLUG` alongside it; this
198/// path set neither, so a reader that wanted the unambiguous form had
199/// nothing to read. After this, both are always present when the server
200/// knows them, and later phases can migrate readers off `AGENTMUX_AGENT_ID`
201/// one at a time.
202///
203/// **Precedence.** The server's own knowledge wins, `cmd:env` is the
204/// fallback — the reverse of the "user-provided values take precedence"
205/// rule the surrounding identity variables follow, deliberately:
206///
207/// - `display_name` is `block.meta["agentName"]`, the value the primary
208/// reactive registration is keyed on (see the Register-tail of
209/// `run_agent_turn`) and the name the pane header shows. A `cmd:env` copy
210/// is a launch-time snapshot that a rename (`setViewName`) leaves stale.
211/// - `persisted_slug` is `db_agents.slug` for this block's agent — the
212/// collision-resolved value `agent_def_insert` minted. A `cmd:env` copy is
213/// whatever the frontend computed before that row existed.
214///
215/// When the two disagree it is logged, not silently resolved: a disagreement
216/// is exactly the kind of signal spec §9.2 says a phase must be able to
217/// observe.
218///
219/// **What this does not do.** `AGENTMUX_AGENT_ID` is never read or written
220/// here. Spec §9.4 keeps its present value on both paths through M4; the
221/// git identity, `MUXBUS_AGENT_ID`, the PR-body tag and the OSC prompt hook
222/// all still derive from it, unchanged.
223pub(crate) fn split_agent_identity_env(
224 env_vars: &mut std::collections::HashMap<String, String>,
225 display_name: &str,
226 persisted_slug: Option<&str>,
227) {
228 let set_authoritative = |env_vars: &mut std::collections::HashMap<String, String>,
229 key: &str,
230 value: &str| {
231 let value = value.trim();
232 if value.is_empty() {
233 return;
234 }
235 if let Some(existing) = env_vars
236 .get(key)
237 .map(|v| v.trim())
238 .filter(|v| !v.is_empty())
239 {
240 if existing != value {
241 tracing::info!(
242 key,
243 cmd_env = %existing,
244 authoritative = %value,
245 "spawn env: cmd:env disagrees with the server's value — server wins (identity M0)"
246 );
247 }
248 }
249 env_vars.insert(key.to_string(), value.to_string());
250 };
251 set_authoritative(env_vars, "AGENTMUX_AGENT_DISPLAY", display_name);
252 if let Some(slug) = persisted_slug {
253 set_authoritative(env_vars, "AGENTMUX_AGENT_SLUG", slug);
254 }
255}
256
257/// `AGENTMUX_AGENT_WORKDIR`: the agent's own workspace, which the host-only
258/// "Your workspace" Operator Config entry tells it to clone and work in
259/// (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md §3.4).
260/// Server-set, like `AGENTMUX_AUTH_KEY`: it is the launch cwd (`cmd:cwd`),
261/// not user configuration, so a `cmd:env` copy never wins. Host agents only —
262/// a container's cwd is a host path that means nothing inside the image (the
263/// key is also on `CONTAINER_ENV_DENYLIST`).
264///
265/// Exported as the absolute path the process actually starts in: `~` is
266/// expanded and a relative `cmd:cwd` is resolved against srv's own cwd, as
267/// `core::apply_working_dir` does, so `cd "$AGENTMUX_AGENT_WORKDIR"` works
268/// from anywhere (Codex on #4113).
269pub(crate) fn carry_agent_workdir_env(
270 env_vars: &mut std::collections::HashMap<String, String>,
271 block_meta: &crate::backend::obj::MetaMapType,
272) {
273 const KEY: &str = "AGENTMUX_AGENT_WORKDIR";
274 let agent_mode = crate::backend::obj::meta_get_string(block_meta, "agentMode", "host");
275 let work_dir = crate::backend::obj::meta_get_string(block_meta, "cmd:cwd", "");
276 if crate::backend::operator_config_seed::agent_kind(&agent_mode) == "host" && !work_dir.trim().is_empty() {
277 let expanded = crate::backend::blockcontroller::core::expand_home_dir(&work_dir);
278 let absolute = std::path::absolute(&expanded).map_or(expanded, |p| p.to_string_lossy().into_owned());
279 env_vars.insert(KEY.to_string(), absolute);
280 } else {
281 env_vars.remove(KEY);
282 }
283}
284
285/// What the server knows about the agent on a block, read from its
286/// `db_agents` row: the identity that will be carried into the process.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub(crate) struct PersistedAgentIdentity {
289 /// `db_agents.id` — the UID (spec §3). A UUID for every non-template row.
290 pub uid: String,
291 /// `db_agents.slug`, the collision-resolved value `agent_def_insert`
292 /// minted. `None` if the row's slug is empty.
293 pub slug: Option<String>,
294 /// The agent's local identity token (`storage/agent_tokens.rs`), minted
295 /// on first spawn. `None` only if minting failed — logged, and the
296 /// spawn proceeds without it, because M1 has no reader for it yet.
297 pub token: Option<String>,
298}
299
300/// The `db_agents` row for the agent shown on `block_id`, if it has one,
301/// plus that agent's token.
302///
303/// Resolves the block → row the same way the identity injector does
304/// (`identity/resolver/inject.rs`): `block.meta.agentId` first, then the
305/// latest active launch on this block. A quick-launch pane (`launchAgent`
306/// in `agent-model.ts`, whose `agentId` is a provider key like `"claude"`)
307/// has no row and gets `None` — it has no slug or UID in the identity
308/// sense, and deriving either from its display name would recreate the
309/// lossy path spec §1.1 measured as the root defect.
310///
311/// The token is minted here, at spawn, rather than at row creation: every
312/// row that exists today predates the table, so "mint at creation" would
313/// leave them all without one. `agent_token_ensure` is idempotent, so a
314/// row created in the future gets the same token on every spawn.
315///
316/// A store error is logged and treated as "unknown", never as a spawn
317/// failure: this is disambiguation, not a gate, and spec §10 constraint 1
318/// forbids a store read from making a healthy agent unreachable.
319pub(crate) fn persisted_agent_identity(
320 mstore: &crate::backend::storage::store::Store,
321 block_id: &str,
322) -> Option<PersistedAgentIdentity> {
323 let instance = match mstore.instance_get_active_for_block(block_id) {
324 Ok(Some(instance)) if !instance.id.trim().is_empty() => instance,
325 Ok(_) => return None,
326 Err(e) => {
327 tracing::warn!(block_id, error = %e, "spawn env: instance lookup failed — no persisted identity from store");
328 return None;
329 }
330 };
331 let slug = match mstore.agent_def_get(&instance.definition_id) {
332 Ok(Some(def)) if !def.slug.trim().is_empty() => Some(def.slug),
333 Ok(_) => None,
334 Err(e) => {
335 tracing::warn!(block_id, error = %e, "spawn env: definition lookup failed — no AGENTMUX_AGENT_SLUG from store");
336 None
337 }
338 };
339 let token = match mstore.agent_token_ensure(&instance.id) {
340 Ok(token) => Some(token),
341 Err(e) => {
342 tracing::warn!(block_id, uid = %instance.id, error = %e, "spawn env: token mint failed — spawning without AGENTMUX_AGENT_TOKEN");
343 None
344 }
345 };
346 // Identity M4d-2: the UID's own signing keys, copied from its name's on
347 // ownership evidence or minted. Only token-carrying spawns get them (the
348 // ones that will sign v2, M4d-6); nothing reads them yet. Like the token,
349 // a store error is logged, never a spawn failure.
350 if token.is_some() {
351 if let Err(e) = mstore.agent_uid_keys_ensure(&instance.id) {
352 tracing::warn!(block_id, uid = %instance.id, error = %e, "spawn env: UID key ensure failed");
353 }
354 }
355 Some(PersistedAgentIdentity {
356 uid: instance.id,
357 slug,
358 token,
359 })
360}
361
362/// Identity M1 — spec §9.1: carry the UID and the token into the process.
363///
364/// `AGENTMUX_AGENT_UID` and `AGENTMUX_AGENT_TOKEN` are RESERVED,
365/// server-controlled values in exactly the sense `AGENTMUX_AUTH_KEY` and
366/// `AGENTMUX_BLOCKID` are at the top of `build_persistent_spawn_env`:
367/// unconditional `insert`, never `entry().or_insert()`. A persisted
368/// `cmd:env` carrying a stale value for either — a pane reused for a
369/// different agent, a copied block — must not survive into this spawn, and
370/// for the token specifically a stale value would be *another agent's*
371/// credential. So when the server knows the identity it overwrites, and
372/// when it does not (no row: quick-launch pane, or a lookup failure) it
373/// **removes** both rather than leaving whatever was there.
374///
375/// Spec §4.1: identity is asserted by whoever minted it. The frontend is
376/// not consulted (its `agentInstanceId` is documented as going stale on
377/// pane reuse), and the agent is not asked. The row is the source.
378///
379/// Descendant inheritance is real and not claimed otherwise (spec §6.4):
380/// the agent's own subprocesses — its shell tool, its MCP servers, build
381/// scripts — inherit the token. That is a smaller blast radius than the
382/// instance-wide `AGENTMUX_AUTH_KEY` they already inherit, not zero. The
383/// `.mcp.json` `agent_config.rs` writes into the working directory carries
384/// neither variable (it copies only `AGENTMUX_AGENT_ID`/`_BUS_ID`), so the
385/// token never lands in a file a human may commit.
386pub(crate) fn carry_agent_uid_env(
387 env_vars: &mut std::collections::HashMap<String, String>,
388 identity: Option<&PersistedAgentIdentity>,
389) {
390 const UID: &str = "AGENTMUX_AGENT_UID";
391 const TOKEN: &str = "AGENTMUX_AGENT_TOKEN";
392 match identity {
393 Some(id) => {
394 env_vars.insert(UID.to_string(), id.uid.clone());
395 match &id.token {
396 Some(token) => {
397 env_vars.insert(TOKEN.to_string(), token.clone());
398 }
399 None => {
400 env_vars.remove(TOKEN);
401 }
402 }
403 }
404 None => {
405 env_vars.remove(UID);
406 env_vars.remove(TOKEN);
407 }
408 }
409}
410
411/// #3577: the id of the deleted agent `block_id` names, when the block
412/// resolved to no row (`identity` is `None`) and its agent was deleted —
413/// the builder's last-read re-check of the gate's refusal (Codex P1 on
414/// #3591). `None` whenever a row resolved, or the block names nothing
415/// deleted.
416pub(crate) fn deleted_agent_of_rowless_block(
417 mstore: &crate::backend::storage::store::Store,
418 block_id: &str,
419 identity: Option<&PersistedAgentIdentity>,
420) -> Option<String> {
421 if identity.is_some() {
422 return None;
423 }
424 crate::identity::resolver::block_agent_id(mstore, block_id)
425 .filter(|agent_id| mstore.agent_was_deleted(agent_id))
426}
427
428/// Identity M4b-2 (spec §6.5.8): carry `block_id`'s row UID and token into
429/// the env of a controller that builds its own (App Server, ACP) instead of
430/// going through [`build_persistent_spawn_env`]. Same resolution and the
431/// same reserved-value rules as that builder (stale values are overwritten,
432/// or removed when the block has no row). A synchronous store read and an
433/// idempotent token mint, so on a multi-thread runtime worker it runs under
434/// `block_in_place`, as eager resume does (incident #1782).
435pub(crate) fn carry_block_identity_env(
436 mstore: &crate::backend::storage::store::Store,
437 block_id: &str,
438 env_vars: &mut std::collections::HashMap<String, String>,
439) {
440 let lookup = || persisted_agent_identity(mstore, block_id);
441 let identity = match tokio::runtime::Handle::try_current() {
442 Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
443 tokio::task::block_in_place(lookup)
444 }
445 _ => lookup(),
446 };
447 carry_agent_uid_env(env_vars, identity.as_ref());
448}
449
450/// The slice of [`AppState`] an agent turn needs in order to be started.
451///
452/// Exists so [`run_agent_turn`] can be driven from somewhere other than the
453/// `agentinput` RPC handler it was originally inlined in. The second caller is
454/// the reactive handler's message sender (`bootstrap::install_agent_turn_delivery`):
455/// a `SubprocessController` starts a turn ONLY via this path, so without a
456/// non-RPC entry point every inter-agent message addressed to one was dropped.
457/// See `docs/reports/REPORT_JEKT_DELIVERY_DROPS_SUBPROCESS_AGENTS_2026_09_02.md`.
458///
459/// Field names match the local bindings the extracted body already used, so the
460/// body itself is unchanged by the extraction.
461#[derive(Clone)]
462pub struct AgentTurnDeps {
463 pub mstore: Arc<crate::backend::storage::store::Store>,
464 pub id_store: Arc<crate::backend::storage::store::Store>,
465 pub identity_store: Arc<crate::backend::storage::store::Store>,
466 /// Streaming-bash wrapper auth key — see SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §7.
467 pub auth_key: String,
468 pub broker: Arc<crate::backend::mps::Broker>,
469 pub container_manager: Arc<crate::backend::container::ContainerRuntimeHandle>,
470 /// Named `filestore_gate` because the spawn-gate error frame MUST be
471 /// persisted through it, not merely live-broadcast (reagent P1, PR #2164).
472 pub filestore_gate: Arc<crate::backend::storage::filestore::FileStore>,
473 pub local_web_url: String,
474 pub event_bus_gate: Arc<crate::backend::eventbus::EventBus>,
475}
476
477impl AgentTurnDeps {
478 pub fn from_state(state: &AppState) -> Self {
479 Self {
480 mstore: state.mstore.clone(),
481 id_store: state.id_store.clone(),
482 identity_store: state.identity_store.clone(),
483 auth_key: state.auth_key.clone(),
484 broker: state.broker.clone(),
485 container_manager: state.container_manager.clone(),
486 filestore_gate: state.filestore.clone(),
487 local_web_url: state.local_web_url.clone(),
488 event_bus_gate: state.event_bus.clone(),
489 }
490 }
491}
492
493/// Start one agent turn for `block_id` with `message`.
494///
495/// This is the whole of what `AgentInputCommand` does, verbatim: re-read the
496/// spawn config from block meta, inject identity/muxbus/bashwrap env (failing
497/// closed on the oauth spawn gate), then dispatch to the persistent controller,
498/// the host subprocess path, or the container `docker exec` path as the block's
499/// `agentMode` and controller type require — and finally re-register the agent
500/// for reactive delivery.
501///
502/// Extracted from the `agentinput` handler body with no behavior change so a
503/// second, non-RPC caller can reach it; see [`AgentTurnDeps`].
504/// Whether starting a turn should also (re-)register the agent for reactive
505/// delivery.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum TurnRegistration {
508 /// The RPC / UI path. Registers, exactly as `AgentInputCommand` always has.
509 Register,
510 /// The reactive-delivery path (`bootstrap::install_agent_turn_delivery`).
511 /// Skipping is required, for two independent reasons:
512 ///
513 /// 1. **Redundant.** That caller resolved this block *by looking the agent
514 /// up in the reactive handler's own registry* (`uid_to_block` /
515 /// `name_to_blocks`), so the agent
516 /// is registered by construction. There is nothing to re-register.
517 /// 2. **Deadlock.** `ReactiveHandler::inject_message` holds the global
518 /// `Mutex<Handler>` across the message-sender call, and that sender
519 /// drives this function to completion on the same thread. The
520 /// registration below calls `get_global_handler().register_agent(...)`,
521 /// which locks that same `std::sync::Mutex` — which is NOT reentrant.
522 /// Registering here would block the thread on a lock it already holds,
523 /// wedging the reactive handler process-wide, on essentially every
524 /// successful delivery to a subprocess agent (reagent P0 on PR #2930).
525 Skip,
526}
527
528/// Builds the full spawn environment for a persistent-controller CLI
529/// process: `cmd:env` base → Layer 3 identity/credential gate → the two
530/// RESERVED wrapper variables (unconditionally overwritten —
531/// `AGENTMUX_AUTH_KEY`/`AGENTMUX_BLOCKID`) → the two agent-identity
532/// variables and per-agent git identity (all user-overridable via
533/// `cmd:env`) → bundled/user tools PATH → the reserved guards (`gh_guard`,
534/// `account_login_guard`: no agent gets a human's `gh` login or the
535/// account's cloud login).
536///
537/// Shared by every real spawn of a persistent-controller CLI — this
538/// function's own logic used to live inline in `run_agent_turn` below, and
539/// only there. `PersistentSubprocessController::start()`'s eager-resume
540/// path (`SPEC_PERSISTENT_CONTROLLER_EAGER_RESUME_ON_RECONNECT_2026_09_20.md`)
541/// built its own second, independent copy of a SUBSET of this — codex P1 on
542/// PR #3513 found it had already drifted from this one: missing PATH and
543/// (then) MuxBus-token injection entirely, and using `entry().or_insert()` instead
544/// of an unconditional overwrite for the two reserved wrapper variables
545/// (meaning a stale persisted `cmd:env` value for either would silently
546/// survive across an eager resume, unlike a live message send). One
547/// function, not two copies kept in sync by hand.
548///
549/// `Err(SpawnGateError)` means the caller must NOT spawn — same contract as
550/// `inject_identity_env_async` itself, which this wraps.
551pub(crate) async fn build_persistent_spawn_env(
552 mstore: Arc<crate::backend::storage::store::Store>,
553 id_store: Arc<crate::backend::storage::store::Store>,
554 identity_store: Arc<crate::backend::storage::store::Store>,
555 broker: Option<Arc<crate::backend::mps::Broker>>,
556 block_meta: &crate::backend::obj::MetaMapType,
557 block_id: &str,
558 auth_key: &str,
559 base_env_vars: std::collections::HashMap<String, String>,
560) -> Result<std::collections::HashMap<String, String>, crate::identity::resolver::SpawnGateError> {
561 // Identity injection: look up the active AgentInstance for this block,
562 // resolve its identity_id's bindings, and merge each per-provider env
563 // var into the spawn map. Api-key-class failures are logged and
564 // skipped — the agent CLI launches with whatever resolved cleanly plus
565 // the static cmd:env block. Oauth-class resolution failures are
566 // BLOCKING unless the agent opted into ambient login (layer-3 spawn
567 // gate, SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §2.2).
568 // Kept for the identity-M0 slug lookup below; `mstore` itself moves into
569 // the identity injector.
570 let mstore_for_slug = mstore.clone();
571 let mut env_vars = crate::identity::resolver::inject_identity_env_async(
572 mstore,
573 id_store.clone(),
574 identity_store,
575 broker,
576 block_id.to_string(),
577 base_env_vars,
578 )
579 .await?;
580
581 // No MuxBus account login here: agents reach the cloud through the srv,
582 // which uses its own stored login (see `account_login_guard`).
583
584 // Streaming-bash wrapper auth + discovery
585 // (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §7).
586 //
587 // 1. AGENTMUX_AUTH_KEY — config.rs's `Config::from_env_and_args` removed
588 // it from the process env at startup (security PR #801). Re-inject for this spawn so the
589 // wrapper (running inside Claude's bash subprocess tree) can
590 // authenticate against the auth_middleware-gated
591 // /agentmux/wps/publish endpoint via X-AuthKey.
592 // 2. PATH — prepend the bundled tools/bin dir so `agentmux-bashwrap.exe`
593 // resolves when the PreToolUse hook (auto-injected by
594 // agent_config.rs) rewrites the command to invoke it.
595 // AGENTMUX_LOCAL_URL is already in the inherited process env
596 // (set by `bind_listeners_and_network` in bootstrap/network.rs).
597 //
598 // Unconditional `insert`, NOT `entry().or_insert()` — these two are
599 // RESERVED, server-controlled values (the current key, the real block
600 // id), not user configuration. A persisted `cmd:env` carrying a stale
601 // value for either (e.g. from a prior server restart with a different
602 // key) must not survive into this spawn.
603 env_vars.insert("AGENTMUX_AUTH_KEY".to_string(), auth_key.to_string());
604 // Block id so the wrapper can scope its MPS publishes to `block:<id>`.
605 // Without this, chunks publish without a scope and the frontend's
606 // per-block subscription doesn't receive them.
607 env_vars.insert("AGENTMUX_BLOCKID".to_string(), block_id.to_string());
608
609 // Identity M0 + M1. M0: name and slug as two explicit variables, on this
610 // path too — the frontend's `launchAgentDefinition` already sets both;
611 // this path never did, so a reader could only get at either by guessing
612 // what `AGENTMUX_AGENT_ID` held (`split_agent_identity_env`). M1: the
613 // UID and the agent's token, from the row the server itself created
614 // (`carry_agent_uid_env`). One store round-trip serves both.
615 {
616 let display_name = crate::backend::obj::meta_get_string(block_meta, "agentName", "");
617 let identity = {
618 let mstore = mstore_for_slug.clone();
619 let owned_block_id = block_id.to_string();
620 match tokio::task::spawn_blocking(move || {
621 let identity = persisted_agent_identity(&mstore, &owned_block_id);
622 // #3577, re-checked here: this is the last store read before
623 // the process starts. The gate refused a deleted agent, but
624 // the agent can be deleted after the gate's lookup succeeded
625 // (the delete handlers share no lock with the spawn path) —
626 // then no row resolves here, and without this the spawn
627 // would go ahead with no identity (Codex P1 on #3591). A
628 // delete after THIS read is a delete of a running agent.
629 let deleted =
630 deleted_agent_of_rowless_block(&mstore, &owned_block_id, identity.as_ref());
631 (identity, deleted)
632 })
633 .await
634 {
635 Ok((_, Some(agent_id))) => {
636 return Err(crate::identity::resolver::SpawnGateError::AgentDeleted {
637 agent_id,
638 });
639 }
640 Ok((identity, None)) => identity,
641 Err(e) => {
642 tracing::warn!(
643 block_id = %block_id,
644 error = %e,
645 "spawn env: persisted-identity lookup task failed — no UID/token this spawn; AGENTMUX_AGENT_SLUG falls back to cmd:env"
646 );
647 None
648 }
649 }
650 };
651 split_agent_identity_env(
652 &mut env_vars,
653 &display_name,
654 identity.as_ref().and_then(|i| i.slug.as_deref()),
655 );
656 carry_agent_uid_env(&mut env_vars, identity.as_ref());
657 super::identity_env::carry_agent_id_env(&mut env_vars, &display_name, identity.as_ref().and_then(|i| i.slug.as_deref()));
658 }
659 carry_agent_workdir_env(&mut env_vars, block_meta);
660 crate::server::browser_uploads::record_launch_workspace(block_id, &env_vars);
661 // Per-agent git commit identity -- see git_identity_env_vars() doc
662 // comment. Still overridable per the same "user-provided values take
663 // precedence" rule as every other var here.
664 if let Some(agent_id) = env_vars.get("AGENTMUX_AGENT_ID").cloned() {
665 for (key, value) in git_identity_env_vars(&agent_id) {
666 if !env_vars.contains_key(key) {
667 env_vars.insert(key.to_string(), value);
668 }
669 }
670 }
671 // PATH gets the bundled tools dir (portable builds, runtime/tools/bin/)
672 // and the user tools dir (~/.agentmux/tools/bin/), both ahead of the
673 // inherited PATH: bundled is None in `task dev` (tool_store excludes
674 // target/debug), so the wrapper is in the user dir and a system copy must
675 // not shadow it. See `UserToolsPrecedence`.
676 {
677 let existing = env_vars
678 .get("PATH")
679 .cloned()
680 .or_else(|| std::env::var("PATH").ok())
681 .unwrap_or_default();
682 if let Some(path) = crate::backend::tool_store::tools_path(
683 &existing,
684 crate::backend::tool_store::UserToolsPrecedence::BeforeExisting,
685 ) {
686 env_vars.insert("PATH".to_string(), path);
687 }
688 }
689 super::bash_prefix::apply_for_spawn(&mut env_vars, block_id);
690 // Plain `gh` must not act as a human's gh login (see `gh_guard`), and no
691 // agent holds the account's cloud login (see `account_login_guard`). Last,
692 // so nothing above — a persisted `cmd:env`, an identity binding — can set
693 // them back: these are reserved variables, not defaults.
694 crate::backend::gh_guard::apply_gh_guard(&mut env_vars);
695 crate::backend::account_login_guard::strip_account_login(&mut env_vars);
696
697 Ok(env_vars)
698}
699
700/// Why `run_agent_turn` refused to spawn, before any process started.
701enum TurnGate {
702 /// Another AgentMux instance on this host runs this agent.
703 Admission(String),
704 /// The identity/credential spawn gate refused.
705 Identity(crate::identity::resolver::SpawnGateError),
706}
707
708impl std::fmt::Display for TurnGate {
709 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710 match self {
711 TurnGate::Admission(refusal) => f.write_str(refusal),
712 TurnGate::Identity(e) => write!(f, "{e}"),
713 }
714 }
715}
716
717/// A held message's join hint, armed for one dispatch. Dropped without
718/// `keep` (the dispatch failed), it is cleared, so no later message can take
719/// it (#4492).
720struct ArmedJoinHint(Option<std::sync::Arc<crate::backend::blockcontroller::health::TurnActivityTracker>>);
721
722impl ArmedJoinHint {
723 fn arm(
724 joins_turn: Option<u64>,
725 tracker: Option<std::sync::Arc<crate::backend::blockcontroller::health::TurnActivityTracker>>,
726 ) -> Self {
727 match (joins_turn, tracker) {
728 (Some(turn_id), Some(tracker)) => {
729 tracker.hint_join(turn_id);
730 Self(Some(tracker))
731 }
732 _ => Self(None),
733 }
734 }
735
736 /// The dispatch went through: the pass it starts takes the hint.
737 fn keep(mut self) {
738 self.0 = None;
739 }
740}
741
742impl Drop for ArmedJoinHint {
743 fn drop(&mut self) {
744 if let Some(tracker) = self.0.take() {
745 tracker.clear_join_hint();
746 }
747 }
748}
749
750pub async fn run_agent_turn(
751 deps: &AgentTurnDeps,
752 block_id: String,
753 message: String,
754 message_id: Option<String>,
755 registration: TurnRegistration,
756 origin: crate::backend::blockcontroller::health::TurnOrigin,
757 attachments: Vec<AttachmentRef>,
758) -> Result<(), String> {
759 run_agent_turn_joining(deps, block_id, message, message_id, registration, origin, attachments, None).await
760}
761
762/// [`run_agent_turn`] for a message the pane held while turn `joins_turn`
763/// ran: if that turn is still settling (or only just ended), the message
764/// joins it rather than starting a new one (turn-model spec §4.3, J3).
765#[allow(clippy::too_many_arguments)]
766pub async fn run_agent_turn_joining(
767 deps: &AgentTurnDeps,
768 block_id: String,
769 message: String,
770 message_id: Option<String>,
771 registration: TurnRegistration,
772 origin: crate::backend::blockcontroller::health::TurnOrigin,
773 attachments: Vec<AttachmentRef>,
774 joins_turn: Option<u64>,
775) -> Result<(), String> {
776 let AgentTurnDeps {
777 mstore,
778 id_store,
779 identity_store,
780 auth_key,
781 broker,
782 container_manager,
783 filestore_gate,
784 local_web_url,
785 event_bus_gate,
786 } = deps.clone();
787
788 let ctrl = blockcontroller::get_controller(&block_id)
789 .ok_or_else(|| format!("no controller for block {}", block_id))?;
790 // A message the pane held during a turn joins that turn (turn-model spec
791 // §4.3, J3). The persistent controller carries it on the input itself;
792 // the others start passes with no labelled input, so their tracker is told
793 // just before the dispatch, never earlier: a failure in the preparation
794 // must not leave the hint armed for a later message (#4492).
795 let arm_join_hint = || ArmedJoinHint::arm(joins_turn, ctrl.turn_tracker());
796
797 // Re-read the spawn config from block metadata
798 let block: Block = mstore
799 .get(&block_id)
800 .map_err(|e| format!("agentinput: load block: {e}"))?
801 .ok_or_else(|| format!("block {} not found", block_id))?;
802
803 let cli_command = crate::backend::obj::meta_get_string(&block.meta, "cmd", "claude");
804 let cli_args: Vec<String> = match block.meta.get("cmd:args") {
805 Some(serde_json::Value::Array(arr)) => arr
806 .iter()
807 .filter_map(|v| v.as_str().map(|s| s.to_string()))
808 .collect(),
809 _ => vec![
810 "-p".to_string(),
811 "--input-format".to_string(),
812 "stream-json".to_string(),
813 "--output-format".to_string(),
814 "stream-json".to_string(),
815 ],
816 };
817 // A pane stored without --model/--effort (saved before the launch fixes, or
818 // written by a path that forgot) would spawn on the CLI default; fill what
819 // its `agent:runtime` says. Never overrides a flag already there.
820 let cli_args = crate::backend::agent_runtime::with_runtime_flags(&block.meta, cli_args);
821 let working_dir = crate::backend::obj::meta_get_string(&block.meta, "cmd:cwd", "");
822 let env_vars = crate::backend::blockcontroller::cmd_env_of(&block.meta);
823 // Identity gate, MuxBus token, reserved wrapper vars, agent identity,
824 // git identity, tools PATH — see `build_persistent_spawn_env`'s own doc
825 // comment. Broker hand-in lets the identity resolver publish
826 // `identity:no-direct-links`, and its OAuth expiry probe
827 // `identityaccounts:changed` when it flips a token's status.
828 //
829 // Before all of that — the env build writes shared identity state — a
830 // persistent pane about to spawn checks that no other AgentMux instance
831 // on this host runs this agent (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24
832 // Phase 1, I9). A refusal takes the gate's error path below, so it is
833 // persisted and shown in the pane like any other spawn refusal.
834 let admission = match ctrl
835 .as_any()
836 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
837 {
838 Some(p) if p.needs_spawn() => {
839 let uid = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
840 let name = crate::backend::obj::meta_get_string(&block.meta, "agentName", "");
841 p.check_admission(&uid, &name).await
842 }
843 _ => Ok(()),
844 };
845 let env_result = match admission {
846 Err(refusal) => Err(TurnGate::Admission(refusal)),
847 Ok(()) => build_persistent_spawn_env(
848 mstore.clone(),
849 id_store.clone(),
850 identity_store.clone(),
851 Some(broker.clone()),
852 &block.meta,
853 &block_id,
854 &auth_key,
855 env_vars,
856 )
857 .await
858 .map_err(TurnGate::Identity),
859 };
860 // Show a refusal that happened before any process ran in the pane: the
861 // raw frame in the output log, plus the classified failure the recovery
862 // row reads. Used by the gate below and by a single-live-instance
863 // refusal from the persistent spawn/fence path further down.
864 let surface_refusal = |message: &str| {
865 let error_frame = serde_json::json!({
866 "type": "result",
867 "is_error": true,
868 "subtype": "error_during_execution",
869 "error": {"message": format!("[AgentMux] {message}")}
870 })
871 .to_string();
872 // Some(&filestore_gate): the frame must be PERSISTED
873 // to the block file, not just live-broadcast —
874 // otherwise the error vanishes on pane
875 // reload/reconnect (reagent P1, PR #2164 round 2). And to
876 // the agent's global zone: a pane with an agentId reads
877 // that on reload, so without it the frame still vanished
878 // (Phase 5a-3c).
879 let global_zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(
880 &Some(mstore.clone()),
881 &block_id,
882 );
883 crate::backend::blockcontroller::shell::handle_append_block_file(
884 &broker,
885 &block_id,
886 crate::backend::blockcontroller::subprocess::SUBPROCESS_OUTPUT_SUBJECT,
887 format!("{error_frame}\n").as_bytes(),
888 Some(&filestore_gate),
889 global_zone.as_deref(),
890 );
891 // codex P1, PR #2802: the frame above only lands in
892 // the block's raw output log, which the recovery
893 // banner does NOT read from — it reads the
894 // structured `agent:last_failure` block-meta key
895 // (persist_last_failure) + the ephemeral
896 // EVENT_AGENT_FAILURE push, same as every POST-spawn
897 // exit classification (host_spawn.rs). No process
898 // ever ran here, so classify() gets no exit
899 // code/stderr/result-frame — just the gate's own
900 // Display text, exactly like health.rs's in-band-error
901 // reclassification call.
902 let gate_failure =
903 crate::agents::failure::classify(None, None, message, None);
904 crate::backend::blockcontroller::core::persist_last_failure(
905 &block_id,
906 Some(&gate_failure),
907 &Some(mstore.clone()),
908 &Some(event_bus_gate.clone()),
909 );
910 broker.publish_event::<crate::backend::mps_events::AgentFailed>(&gate_failure, vec![format!("block:{}", block_id)], 1);
911 };
912 let mut env_vars = match env_result {
913 Ok(env) => env,
914 Err(gate) => {
915 surface_refusal(&gate.to_string());
916 return Err(match &gate {
917 TurnGate::Admission(refusal) => refusal.clone(),
918 TurnGate::Identity(e) => format!("identity spawn gate: {e}"),
919 });
920 }
921 };
922 // Identity M2: the UID this turn's spawn env carries (M1a), kept for the
923 // Register-tail below so registration is keyed by identity without a
924 // second store read (spec §4.4.1 row 2). `None` when the block has no
925 // row yet — the tail then registers by name, counted.
926 let turn_uid: Option<String> = env_vars
927 .get("AGENTMUX_AGENT_UID")
928 .map(|u| u.trim().to_string())
929 .filter(|u| !u.is_empty());
930 let turn_agent_id = env_vars.get("AGENTMUX_AGENT_ID").cloned(); // the stable binding, as spawn parks it
931
932 let session_id_field =
933 crate::backend::obj::meta_get_string(&block.meta, "agent:session_id_field", "session_id");
934
935 // Attachments (SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6.6,
936 // SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md §7): every provider gets
937 // a numbered list of the files appended to the text; Claude's persistent
938 // stream-json path also gets images and short PDFs inline. Container
939 // agents can't reach host paths yet (their panes copy files into the
940 // working folder instead), so they refuse rather than send a list the
941 // agent can't open.
942 let mut inline_images: Vec<serde_json::Value> = Vec::new();
943 let message = if attachments.is_empty() {
944 message
945 } else {
946 let agent_mode = crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host");
947 // Refusals go through surface_refusal like every other early exit
948 // here, so the reason survives a pane reload.
949 if agent_mode == "container" {
950 let msg = "Attachments aren't supported for container agents yet.";
951 surface_refusal(msg);
952 return Err(msg.to_string());
953 }
954 let Some(svc) = crate::backend::attachments::get() else {
955 let msg = "Image attachments are unavailable: the attachment service isn't running.";
956 surface_refusal(msg);
957 return Err(msg.to_string());
958 };
959 let is_persistent = ctrl
960 .as_any()
961 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
962 .is_some();
963 let limits = svc.limits();
964 let inline_max = if is_persistent { limits.claude_inline_max } else { 0 };
965 // Claude Code keeps every inline image in its own session transcript,
966 // so inline bytes are budgeted per session too (spec §6.6). A new
967 // session has no id yet; its first turn counts against the block.
968 // Once the id is known, that first turn's count moves to the session.
969 let sid = crate::backend::obj::meta_get_string(&block.meta, "agent:sessionid", "");
970 let new_session_key = format!("block:{block_id}:new");
971 let session_key = if sid.is_empty() { new_session_key.clone() } else { format!("session:{sid}") };
972 // Re-derive any send-copy whose fingerprint is stale (a changed send
973 // edge) under the service's CPU and memory limits, before the
974 // blocking resolve below looks the files up.
975 for a in &attachments {
976 svc.ensure_derived(&a.id).await;
977 }
978 let prepared = tokio::task::spawn_blocking(move || {
979 use crate::backend::attachments::prompt;
980 let (found, missing) = prompt::resolve(&svc, &attachments);
981 // Read, spend and record the session budget as one locked step.
982 svc.with_session_inline_budget(
983 &session_key,
984 Some(&new_session_key),
985 limits.claude_session_inline_bytes,
986 |budget| {
987 let prepared = prompt::prepare(&found, &missing, inline_max, budget);
988 let spent = prompt::inline_bytes(&prepared.inline);
989 (prepared, spent)
990 },
991 )
992 })
993 .await
994 .map_err(|e| format!("attachments: {e}"))?;
995 inline_images = prepared.inline;
996 crate::backend::attachments::prompt::append(&message, &prepared.list)
997 };
998
999 // App Server owns a persistent thread/turn process and has its own typed
1000 // protocol path. Keep it ahead of the legacy stream-json branches.
1001 //
1002 // KNOWN GAP (ReAgent P1, PR #3215, tracked for a follow-up PR, not fixed
1003 // here): env_vars above (including this turn's freshly-resolved identity
1004 // bindings from inject_identity_env_async) is computed but never reaches
1005 // this branch — send_message carries no env, and the App Server child was
1006 // already spawned earlier using only command_from_meta's cmd:env snapshot.
1007 // Unlike the subprocess/persistent controllers, this isn't a missing
1008 // plumbing call: env vars are fixed at process exec() time, so an
1009 // already-running App Server process cannot pick up a per-turn identity
1010 // change at all without being torn down and respawned. Deciding when to
1011 // force that respawn (e.g. on every identity-binding change vs. only
1012 // between turns) is a real design question, not a mechanical fix, so it's
1013 // deliberately left for a follow-up PR rather than rushed in here.
1014 if let Some(app_server_ctrl) =
1015 ctrl.as_any()
1016 .downcast_ref::<blockcontroller::app_server_controller::AppServerController>()
1017 {
1018 let hint = arm_join_hint();
1019 app_server_ctrl.send_message(message)?;
1020 hint.keep();
1021 }
1022 // Try persistent controller first, fall back to subprocess
1023 else if let Some(persistent_ctrl) =
1024 ctrl.as_any()
1025 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
1026 {
1027 // Container agents use per-turn docker exec — incompatible with a
1028 // long-lived persistent subprocess. Fail loudly instead of silently
1029 // spawning the CLI on the host.
1030 let agent_mode = crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host");
1031 if agent_mode == "container" {
1032 return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
1033 }
1034 // Resume support: a /model (or effort/permission) change
1035 // respawns the persistent CLI with new flags; pass the
1036 // resume flag + captured session id so the respawn continues
1037 // the same conversation. Same meta keys the subprocess path
1038 // reads below. Without this, switching model on a persistent
1039 // agent would either no-op (old behavior) or lose context.
1040 let resume_flag =
1041 crate::backend::obj::meta_get_string(&block.meta, "agent:resume_flag", "--resume");
1042 let persisted_session_id =
1043 crate::backend::obj::meta_get_string(&block.meta, "agent:sessionid", "");
1044 let config = blockcontroller::persistent::PersistentSpawnConfig {
1045 cli_command,
1046 cli_args,
1047 working_dir,
1048 env_vars,
1049 session_id_field,
1050 resume_flag,
1051 session_id: persisted_session_id,
1052 message_id: message_id.clone(),
1053 };
1054 let input = crate::backend::blockcontroller::health::TurnInput { origin, text: message.clone(), joins_turn };
1055 if let Err(e) = persistent_ctrl.send_message_with_images_from(message, inline_images, config, Some(input)) {
1056 // A single-live-instance refusal from inside the controller (the
1057 // spawn-time claim losing to another instance, or the pre-turn
1058 // fence) gets the same pane treatment as the early check above.
1059 if crate::backend::agent_admission::is_admission_refusal(&e) {
1060 surface_refusal(&e);
1061 }
1062 return Err(e);
1063 }
1064 } else if let Some(subprocess_ctrl) =
1065 ctrl.as_any()
1066 .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
1067 {
1068 let resume_flag =
1069 crate::backend::obj::meta_get_string(&block.meta, "agent:resume_flag", "--resume");
1070 let resume_strategy = crate::backend::obj::meta_get_string(
1071 &block.meta,
1072 "agent:resume_strategy",
1073 if session_id_field == "thread_id" {
1074 "codex-exec"
1075 } else if resume_flag.is_empty() {
1076 "none"
1077 } else {
1078 "flag"
1079 },
1080 );
1081 // Antigravity takes its prompt as `-p <prompt>`, not on stdin.
1082 let prompt_arg_flag =
1083 crate::backend::obj::meta_get_string(&block.meta, "agent:prompt_arg_flag", "");
1084 // Picker reattach: the frontend writes the prior
1085 // block's session id here when launching with
1086 // `continueOfInstanceId`. spawn_turn hydrates its
1087 // inner.session_id from this on the first turn so
1088 // --resume <sid> lands on the very first launch.
1089 let persisted_session_id =
1090 crate::backend::obj::meta_get_string(&block.meta, "agent:sessionid", "");
1091
1092 // Container agent branch: use Docker socket API exec (P1a: no
1093 // secrets in argv). Host agent branch: regular CLI subprocess.
1094 let agent_mode = crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host");
1095 // Cross-process session-lease key (registry::LeaseStore) —
1096 // read once here for both branches below. Only the host
1097 // branch's spawn_turn actually enforces it in this PR;
1098 // the container branch's config field is unused for now
1099 // (struct-completeness — see host_spawn.rs's doc comment).
1100 let instance_id = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
1101 if agent_mode == "container" {
1102 let cm = container_manager.get().await.ok_or_else(|| {
1103 "Docker not available on this host; cannot start container agent".to_string()
1104 })?;
1105 let container_image =
1106 crate::backend::obj::meta_get_string(&block.meta, "agent:container_image", "");
1107 // Use agentId (UUID) — always valid as a Docker name; display names can have spaces.
1108 let agent_id = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
1109 let container_name = crate::backend::container::container_name_for_slug(&agent_id);
1110 let volumes_json =
1111 crate::backend::obj::meta_get_string(&block.meta, "agent:container_volumes", "[]");
1112 let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
1113
1114 // Mount the bound account's credentials and the agent's working
1115 // directory. `CLAUDE_CONFIG_DIR` was resolved into `env_vars` by the
1116 // identity injection above, but it is a HOST path and so is stripped
1117 // by CONTAINER_ENV_DENYLIST before exec — mounting it is the only way
1118 // the in-container CLI ever sees `.credentials.json`. Without this a
1119 // container agent authenticates never, no matter how many times the
1120 // operator logs in via Connectors → Accounts.
1121 let mount_spec = crate::backend::container::ContainerMountSpec {
1122 claude_config_host_dir: env_vars
1123 .get("CLAUDE_CONFIG_DIR")
1124 .and_then(|d| crate::backend::container::credentials_dir_if_file_backed(d)),
1125 workspace_host_dir: Some(working_dir.clone()).filter(|d| !d.is_empty()),
1126 };
1127
1128 // AGENTMUX_LOCAL_URL is never explicitly set in env_vars for a host
1129 // agent either (see the PATH-block comment above) — a host
1130 // subprocess just inherits it from this process's own env
1131 // (bootstrap/network.rs). `docker exec` over the Docker socket never
1132 // inherits host process env, so a container agent gets no sidecar
1133 // URL at all unless it's injected here. Rewritten to
1134 // host.docker.internal (routable from inside the container via
1135 // the extra_hosts entry `create_and_start` sets — see
1136 // container.rs) since the loopback address this process sees is
1137 // not reachable from the container's own network namespace.
1138 // #2939 workstream 1 (host integration) — the other half is the
1139 // agentmux-mcp/agentmux-bashwrap binaries that
1140 // docker/Dockerfile.agent-agentmux builds into the image.
1141 if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
1142 env_vars.insert(
1143 "AGENTMUX_LOCAL_URL".to_string(),
1144 crate::backend::container::rewrite_local_url_for_container(&local_url),
1145 );
1146 }
1147
1148 // The command and provider the turn runs, resolved INSIDE the image
1149 // (on PATH, e.g. `claude`) -- not `cli_command`/`cmd`, which is the
1150 // host-resolved absolute npm path and does not exist in the
1151 // container.
1152 let container_command = crate::backend::obj::meta_get_string(
1153 &block.meta,
1154 "agent:container_command",
1155 "claude",
1156 );
1157 let agent_provider =
1158 crate::backend::obj::meta_get_string(&block.meta, "agentProvider", "claude");
1159
1160 // Start the container (pulling the image if needed) and make sure
1161 // the provider CLI is installed in it. A failure is written to the
1162 // pane as a plain-language error before it is returned.
1163 super::container_turn::prepare(super::container_turn::ContainerTurnPrep {
1164 cm: &cm,
1165 block_id: &block_id,
1166 container_name: &container_name,
1167 image: &container_image,
1168 volumes: &volumes,
1169 mount_spec: &mount_spec,
1170 provider_id: &agent_provider,
1171 container_command: &container_command,
1172 broker: &broker,
1173 filestore: &filestore_gate,
1174 mstore: &mstore,
1175 })
1176 .await?;
1177
1178 tracing::info!(
1179 block_id = %block_id,
1180 container = %container_name,
1181 image = %crate::backend::container_image::resolve_container_image(&container_image),
1182 "container agent turn: bollard exec (env via Docker socket, not argv)",
1183 );
1184
1185 // Env is passed via CreateExecOptions.env (Docker socket API),
1186 // NOT as -e KEY=VALUE argv args — this prevents CWE-214 exposure.
1187 // spawn_container_turn filters config.env_vars (denylist) per
1188 // turn, so cmd:cwd (host path) and host-path vars never reach
1189 // the container, and each queued turn uses its own env.
1190
1191 // Base cmd: [container_command, ...cli_args]. cli_args are format
1192 // flags (-p, --input-format ...) + provider flags -- no host
1193 // paths, safe as-is. spawn_container_turn appends --resume <sid>
1194 // internally. `agent_provider` drives the one-shot argv rebuild.
1195 let mut base_cmd = vec![container_command];
1196 base_cmd.extend(container_argv(cli_args, &agent_provider));
1197
1198 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
1199 cli_command: String::new(), // unused by spawn_container_turn
1200 cli_args: vec![], // unused by spawn_container_turn
1201 working_dir: String::new(), // unused — container has own cwd
1202 env_vars,
1203 message: message,
1204 resume_flag,
1205 resume_strategy,
1206 prompt_arg_flag,
1207 session_id_field,
1208 message_id: message_id,
1209 session_id: if persisted_session_id.is_empty() {
1210 None
1211 } else {
1212 Some(persisted_session_id)
1213 },
1214 instance_id: instance_id.clone(),
1215 };
1216 let hint = arm_join_hint();
1217 subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
1218 hint.keep();
1219 } else {
1220 // Host agent: regular CLI subprocess (env set on child process, not in argv).
1221 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
1222 cli_command,
1223 cli_args,
1224 working_dir,
1225 env_vars,
1226 message: message,
1227 resume_flag,
1228 resume_strategy,
1229 prompt_arg_flag,
1230 session_id_field,
1231 message_id: message_id,
1232 session_id: if persisted_session_id.is_empty() {
1233 None
1234 } else {
1235 Some(persisted_session_id)
1236 },
1237 instance_id,
1238 };
1239 let hint = arm_join_hint();
1240 subprocess_ctrl.spawn_turn(config)?;
1241 hint.keep();
1242 }
1243 } else if let Some(acp_ctrl) =
1244 ctrl.as_any().downcast_ref::<blockcontroller::acp::AcpController>()
1245 {
1246 // ACP agents (Copilot, OpenClaw, Pi): the message becomes a
1247 // `session/prompt` on the agent's running session.
1248 // If the agent's process exited or crashed, this starts it again from
1249 // the pane's meta; the message waits for the new session.
1250 acp_ctrl.ensure_started(block.meta.clone())?;
1251 let hint = arm_join_hint();
1252 acp_ctrl.send_message(message, message_id.as_deref())?;
1253 hint.keep();
1254 } else {
1255 return Err(
1256 "controller is not a SubprocessController, PersistentSubprocessController or AcpController"
1257 .to_string(),
1258 );
1259 }
1260
1261 // Registration is skipped on the reactive-delivery path — see
1262 // `TurnRegistration::Skip` for why that is both redundant AND a hard
1263 // deadlock requirement, not an optimisation.
1264 if matches!(registration, TurnRegistration::Register) {
1265 // Register with cloud subscriber + reactive handler so cloud-injected
1266 // messages (e.g. GitHub PR review notifications) reach this agent.
1267 // Uses agentName (the logical display name, e.g. "smike") as the name
1268 // binding — matching the namespace used by `handle_reactive_register`
1269 // in server/reactive.rs (`req.agent_id`) and the delivery path's
1270 // lowercased `name_to_blocks` map — with this turn's UID as the key.
1271 // PR bodies embed $AGENTMUX_AGENT_ID, the stable binding (a fork's
1272 // `loap-3` under `Loap-3`), so both are subscribed (idempotently).
1273 // As its route name for every use below ("Loap #3" → "Loap-3"), or the
1274 // controller's own id disagrees with what a jekt resolves to (#2695).
1275 let display = crate::backend::obj::meta_get_string(&block.meta, "agentName", "");
1276 let agent_name = crate::backend::reactive::sanitize::route_name(&display).unwrap_or_default();
1277 if !agent_name.is_empty() {
1278 // Identity M2: the display binding is `agentName`, the key is
1279 // the UID carried in this turn's spawn env. A block that spawned
1280 // before its row existed (continuation eager-resume, spec §4.4.4
1281 // Q1) is upgraded in place here on its first turn.
1282 let registered = crate::backend::reactive::handler::get_global_handler()
1283 .register_agent_full(
1284 &agent_name,
1285 &block_id,
1286 None,
1287 0,
1288 turn_agent_id.as_deref(),
1289 turn_uid.as_deref(),
1290 "registration.no_uid.register_tail",
1291 );
1292 if registered.is_ok() {
1293 // Refresh the block's OWN captured identity too
1294 // (reagentx P1, round 2 on #2697): this call runs on
1295 // EVERY turn, using block.meta["agentName"] as the
1296 // source of truth — which can diverge from whatever
1297 // a PersistentSubprocessController captured at its
1298 // original spawn_process call (rename, reconfigured
1299 // cmd:env) without the block ever respawning. Without
1300 // this, inject_message_inner's recipient-identity
1301 // check (#2695) would compare the current (correct)
1302 // target_agent against that stale spawn-time value
1303 // and falsely reject the agent's own, correctly-
1304 // addressed jekts as an identity mismatch. A no-op
1305 // for controller types that don't override
1306 // set_agent_id (e.g. SubprocessController).
1307 if let Some(ctrl) = crate::backend::blockcontroller::get_controller(&block_id) {
1308 ctrl.set_agent_id(Some(agent_name.clone()));
1309 }
1310 if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1311 let names = crate::backend::reactive::handler::get_global_handler().names_for_block(&block_id);
1312 names.iter().for_each(|n| sub.add_agent(n));
1313 }
1314 // Also mirror into the per-channel + host-global
1315 // shared file registries — this AgentInput/
1316 // SubprocessController path (unlike ShellController/
1317 // PersistentSubprocessController) previously only
1318 // ever registered in the in-memory Tier-1 map,
1319 // leaving it permanently unreachable via Tier 2/2b
1320 // cross-instance/cross-channel delivery (reagent P1,
1321 // third round on PR #2350).
1322 let data_dir = crate::backend::base::get_mux_data_dir();
1323 crate::backend::reactive::registry::write(
1324 &data_dir,
1325 &agent_name,
1326 &local_web_url,
1327 &block_id,
1328 );
1329 crate::backend::reactive::registry::write_shared_from_env(
1330 &agent_name,
1331 &local_web_url,
1332 &block_id,
1333 );
1334 }
1335 }
1336 }
1337
1338 Ok(())
1339}
1340
1341pub fn register_agent_input_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
1342 // subprocessspawn → spawn agent CLI as subprocess for a single turn
1343 let mstore_spawn = state.mstore.clone();
1344 let broker_spawn = state.broker.clone();
1345 let event_bus_spawn = state.event_bus.clone();
1346 let filestore_spawn = state.filestore.clone();
1347 let boot_id_spawn = state.boot_id.clone();
1348 engine.register_typed(
1349 COMMAND_SUBPROCESS_SPAWN,
1350 move |cmd: CommandSubprocessSpawnData, _ctx| {
1351 let mstore = mstore_spawn.clone();
1352 let broker = broker_spawn.clone();
1353 let event_bus = event_bus_spawn.clone();
1354 let filestore = filestore_spawn.clone();
1355 let boot_id = boot_id_spawn.clone();
1356 async move {
1357 tracing::info!(
1358 block_id = %cmd.blockid,
1359 cli = %cmd.cli_command,
1360 "SubprocessSpawn"
1361 );
1362
1363 // Get or create a SubprocessController for this block
1364 let ctrl = match blockcontroller::get_controller(&cmd.blockid) {
1365 Some(c)
1366 if c.controller_type() == blockcontroller::BLOCK_CONTROLLER_SUBPROCESS =>
1367 {
1368 c
1369 }
1370 _ => {
1371 // Create and register a new SubprocessController
1372 let registry = mstore.shared_agent_registry();
1373 let ctrl = blockcontroller::subprocess::SubprocessController::new(
1374 cmd.tabid.clone(),
1375 cmd.blockid.clone(),
1376 Some(broker),
1377 Some(event_bus),
1378 Some(mstore),
1379 Some(filestore),
1380 registry,
1381 boot_id,
1382 );
1383 let ctrl = std::sync::Arc::new(ctrl);
1384 ctrl.set_self_ref();
1385 blockcontroller::register_controller(&cmd.blockid, ctrl.clone());
1386 ctrl as std::sync::Arc<dyn blockcontroller::Controller>
1387 }
1388 };
1389
1390 // Downcast to SubprocessController to call spawn_turn
1391 let subprocess_ctrl = ctrl
1392 .as_any()
1393 .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
1394 .ok_or_else(|| "controller is not a SubprocessController".to_string())?;
1395
1396 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
1397 cli_command: cmd.cli_command,
1398 cli_args: cmd.cli_args,
1399 working_dir: cmd.working_dir,
1400 env_vars: cmd.env_vars,
1401 message: cmd.message,
1402 resume_flag: "--resume".to_string(),
1403 resume_strategy: "flag".to_string(),
1404 prompt_arg_flag: String::new(),
1405 session_id_field: "session_id".to_string(),
1406 message_id: None,
1407 // Direct-spawn legacy command — caller doesn't
1408 // carry a reattach context. Greenfield session id
1409 // is None; spawn_turn captures it from CLI stdout
1410 // on the first turn as before.
1411 session_id: None,
1412 // COMMAND_SUBPROCESS_SPAWN has no block to read
1413 // agentId from (dead code — unused by the
1414 // frontend, per grep); empty disables leasing.
1415 instance_id: String::new(),
1416 };
1417 subprocess_ctrl.spawn_turn(config)?;
1418 Ok(())
1419 }
1420 },
1421 );
1422
1423 // agentinput → send message to agent (persistent or per-turn subprocess).
1424 // Everything the turn needs from AppState now travels as one
1425 // `AgentTurnDeps` (see its doc comment for why it is a named struct rather
1426 // than nine separate closure captures).
1427 let deps_ai = AgentTurnDeps::from_state(state);
1428 engine.register_typed(
1429 COMMAND_AGENT_INPUT,
1430 move |cmd: CommandAgentInputData, _ctx| {
1431 let deps = deps_ai.clone();
1432 async move {
1433 tracing::info!(block_id = %cmd.blockid, "AgentInput");
1434 // Authoritative hidden-window gate for ambient digest reads
1435 // (next_prompt_suggestion / activity_summary / activity_watcher)
1436 // — set here, not reconstructed from transcript bytes. See
1437 // crate::ambient::digest::set_hidden_reinjection_active's doc comment.
1438 crate::ambient::digest::set_hidden_reinjection_active(
1439 &cmd.blockid,
1440 cmd.hidden.unwrap_or(false),
1441 );
1442 // A re-delivery srv composed (`memorydelivery:compose`): its
1443 // card is written once the message has gone to the agent
1444 // (below), never for one that didn't start.
1445 let fallback_card = cmd
1446 .hidden
1447 .unwrap_or(false)
1448 .then_some(())
1449 .and(cmd.delivery_id.as_deref())
1450 .and_then(|id| crate::server::memory_delivery_handlers::take_fallback_frame(&cmd.blockid, id));
1451 // A post-compaction memory reinjection also carries the
1452 // agent's running summary (SPEC_DURABLE_CONVERSATION_MEMORY
1453 // §4.4, "Compaction reuse").
1454 let message = if cmd.hidden.unwrap_or(false) {
1455 crate::backend::continuity_state::with_state_after_compaction(&deps.mstore, &cmd.blockid, cmd.message)
1456 } else {
1457 cmd.message
1458 };
1459 // The pane's composer: the user — except a hidden memory
1460 // reinjection, which the frontend sends on its own behalf.
1461 let origin = if cmd.hidden.unwrap_or(false) {
1462 crate::backend::blockcontroller::health::TurnOrigin::System
1463 } else {
1464 crate::backend::blockcontroller::health::TurnOrigin::User
1465 };
1466 let block_id = cmd.blockid.clone();
1467 run_agent_turn_joining(
1468 &deps,
1469 cmd.blockid,
1470 message,
1471 cmd.message_id,
1472 TurnRegistration::Register,
1473 origin,
1474 cmd.attachments.unwrap_or_default(),
1475 cmd.joins_turn,
1476 )
1477 .await?;
1478 // Right after the hidden message's own transcript line: replay
1479 // shows this card in its place (parseHistoryLines.ts).
1480 if let Some(frame) = fallback_card {
1481 let line = format!("{frame}\n");
1482 let zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(
1483 &Some(deps.mstore.clone()),
1484 &block_id,
1485 );
1486 crate::backend::blockcontroller::shell::handle_append_block_file(
1487 &deps.broker,
1488 &block_id,
1489 crate::backend::blockcontroller::persistent::PERSISTENT_OUTPUT_SUBJECT,
1490 line.as_bytes(),
1491 Some(&deps.filestore_gate),
1492 zone.as_deref(),
1493 );
1494 }
1495 Ok(())
1496 }
1497 },
1498 );
1499
1500 // agentstop → stop the running agent subprocess
1501 engine.register_typed(
1502 COMMAND_AGENT_STOP,
1503 |cmd: CommandAgentStopData, _ctx| {
1504 async move {
1505 tracing::info!(block_id = %cmd.blockid, force = cmd.force, "AgentStop");
1506 match blockcontroller::get_controller(&cmd.blockid) {
1507 Some(_) => {
1508 // The one teardown path, Stop policy (SPEC_AGENT_TEARDOWN_SINGLE_PATH §5).
1509 crate::sagas::agent_teardown::stop_now(&cmd.blockid, !cmd.force)?;
1510 // Deregister: unregister_block cleans up both agent_to_block and
1511 // block_to_agent maps; remove_agent then removes the cloud poll entry
1512 // using the logical agent_id recovered from block_to_agent.
1513 let handler = crate::backend::reactive::handler::get_global_handler();
1514 // Identity M2: a block can be bound under two names
1515 // (display + stable); tear down the name-keyed side
1516 // state for EACH, not just the display name — the
1517 // stable name's file entry was written at spawn and
1518 // its cloud subscription would otherwise leak.
1519 let names = handler.unregister_block(&cmd.blockid);
1520 let data_dir = crate::backend::base::get_mux_data_dir();
1521 let sub = crate::muxbus::cloud_subscriber::get_global_subscriber();
1522 for name in &names {
1523 // Symmetric teardown for the registry writes added
1524 // alongside SubprocessSpawn's register_agent call.
1525 crate::backend::reactive::registry::remove(&data_dir, name);
1526 crate::backend::reactive::registry::remove_shared_from_env(name);
1527 if let Some(sub) = sub {
1528 sub.remove_agent(name);
1529 }
1530 }
1531 Ok(())
1532 }
1533 // Stopping a block with no controller is a no-op, not an
1534 // error: the UI cannot tell "already stopped" from "never
1535 // started", and either way the caller's intent is
1536 // satisfied.
1537 None => Ok(()),
1538 }
1539 }
1540 },
1541 );
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546 use super::git_identity_env_vars;
1547 use super::{container_argv, flags_with_arity, strip_flag_with_value};
1548
1549 fn v(xs: &[&str]) -> Vec<String> {
1550 xs.iter().map(|s| s.to_string()).collect()
1551 }
1552
1553 /// The exact argv a container agent carried before the fix — the persistent
1554 /// controller's args, copied verbatim from the live broken block (Moras,
1555 /// 2026-08-31).
1556 fn stale_persistent_argv() -> Vec<String> {
1557 v(&[
1558 "--input-format",
1559 "stream-json",
1560 "--output-format",
1561 "stream-json",
1562 "--verbose",
1563 "--include-partial-messages",
1564 "--permission-prompt-tool",
1565 "stdio",
1566 "--permission-mode",
1567 "default",
1568 "--model",
1569 "sonnet",
1570 "--effort",
1571 "high",
1572 ])
1573 }
1574
1575 /// All three persistent-only defects must go, not just the fatal one
1576 /// (codex P1 on PR #2867): the parse crash, the missing one-shot `-p`, and
1577 /// the control-protocol permission flags a container turn cannot answer.
1578 #[test]
1579 fn rebuilds_a_stale_persistent_argv_into_the_one_shot_form() {
1580 let got = container_argv(stale_persistent_argv(), "claude");
1581
1582 assert!(
1583 !got.iter().any(|a| a == "--input-format"),
1584 "the fatal parse flag must go"
1585 );
1586 assert!(
1587 got.iter().any(|a| a == "-p"),
1588 "one-shot print mode must be present"
1589 );
1590 assert!(
1591 !got.iter().any(|a| a == "--permission-prompt-tool"),
1592 "the container turn has no control channel to answer can_use_tool",
1593 );
1594 assert!(
1595 !got.iter().any(|a| a == "stdio"),
1596 "its value token must go with it"
1597 );
1598 assert!(
1599 !got.iter().any(|a| a == "--permission-mode"),
1600 "non-bypass permission mode needs the control protocol",
1601 );
1602 assert!(
1603 got.iter().any(|a| a == "--output-format"),
1604 "output-format stream-json is how the pane parses the turn at all",
1605 );
1606 }
1607
1608 /// A user's model/effort selections must survive the heal — silently
1609 /// resetting someone's model on every stale pane would be its own bug.
1610 #[test]
1611 fn preserves_the_users_model_and_effort_choices() {
1612 let got = container_argv(stale_persistent_argv(), "claude");
1613 let pos = |f: &str| got.iter().position(|a| a == f);
1614 assert_eq!(got[pos("--model").expect("--model kept") + 1], "sonnet");
1615 assert_eq!(got[pos("--effort").expect("--effort kept") + 1], "high");
1616 }
1617
1618 /// reagent P1 on PR #2867. This runs on EVERY container turn, not once per
1619 /// stale block, so it must not touch an argv the frontend already built
1620 /// correctly — a rebuild-from-baseline silently deleted `provider_flags`
1621 /// and `--fork-session` on every single turn, forever.
1622 #[test]
1623 fn leaves_an_already_correct_one_shot_argv_completely_untouched() {
1624 let correct = v(&[
1625 "-p",
1626 "--output-format",
1627 "stream-json",
1628 "--verbose",
1629 "--include-partial-messages",
1630 "--dangerously-skip-permissions",
1631 "--exclude-dynamic-system-prompt-sections",
1632 "--model",
1633 "opus",
1634 "--my-custom-provider-flag",
1635 "42", // agent.provider_flags
1636 "--fork-session", // resolveForkSessionArgs
1637 ]);
1638 assert_eq!(container_argv(correct.clone(), "claude"), correct);
1639 }
1640
1641 /// …and when it DOES heal a stale argv, those same user-owned tokens still
1642 /// have to come through. Healing is subtraction, not reconstruction.
1643 #[test]
1644 fn a_heal_preserves_provider_flags_and_fork_session_too() {
1645 let mut stale = stale_persistent_argv();
1646 stale.extend(v(&["--my-custom-provider-flag", "42", "--fork-session"]));
1647
1648 let got = container_argv(stale, "claude");
1649
1650 assert!(
1651 got.iter().any(|a| a == "--my-custom-provider-flag"),
1652 "provider_flags survive"
1653 );
1654 assert_eq!(
1655 got[got
1656 .iter()
1657 .position(|a| a == "--my-custom-provider-flag")
1658 .unwrap()
1659 + 1],
1660 "42",
1661 "…with its value",
1662 );
1663 assert!(
1664 got.iter().any(|a| a == "--fork-session"),
1665 "fork-session survives"
1666 );
1667 assert!(
1668 !got.iter().any(|a| a == "--input-format"),
1669 "while still being healed"
1670 );
1671 assert!(got.iter().any(|a| a == "-p"));
1672 }
1673
1674 /// An unknown provider has no baseline to diff against, so fall back to
1675 /// removing only the outright-fatal flag rather than guessing.
1676 #[test]
1677 fn an_unknown_provider_falls_back_to_removing_only_the_fatal_flag() {
1678 let got = container_argv(
1679 v(&["--input-format", "stream-json", "--custom"]),
1680 "no-such-provider",
1681 );
1682 assert_eq!(got, v(&["--custom"]));
1683 }
1684
1685 /// A subprocess-shaped provider has no persistent argv, so nothing it was
1686 /// ever launched with could need healing.
1687 #[test]
1688 fn a_provider_with_no_persistent_variant_is_passed_through() {
1689 let argv = v(&["--json", "--whatever"]);
1690 assert_eq!(container_argv(argv.clone(), "codex"), argv);
1691 }
1692
1693 /// Arity comes off the catalog, not a hardcoded table — this is what lets
1694 /// a valued flag drop its value and a bare flag not eat the next one.
1695 #[test]
1696 fn flags_with_arity_reads_valued_and_bare_flags_off_the_list() {
1697 static ARGS: &[&str] = &["-p", "--output-format", "stream-json", "--verbose"];
1698 assert_eq!(
1699 flags_with_arity(ARGS),
1700 vec![
1701 ("-p", false),
1702 ("--output-format", true),
1703 ("--verbose", false)
1704 ],
1705 );
1706 }
1707
1708 #[test]
1709 fn strip_flag_with_value_removes_every_occurrence_and_tolerates_a_missing_value() {
1710 assert_eq!(
1711 strip_flag_with_value(v(&["--x", "1", "--keep", "--x", "2"]), "--x"),
1712 v(&["--keep"]),
1713 );
1714 assert_eq!(
1715 strip_flag_with_value(v(&["--keep", "--x"]), "--x"),
1716 v(&["--keep"])
1717 );
1718 assert_eq!(strip_flag_with_value(vec![], "--x"), Vec::<String>::new());
1719 }
1720
1721 #[test]
1722 fn maps_agent_id_to_name_and_placeholder_email() {
1723 let vars = git_identity_env_vars("korp");
1724 let map: std::collections::HashMap<&str, String> = vars.into_iter().collect();
1725 assert_eq!(map["GIT_AUTHOR_NAME"], "korp");
1726 assert_eq!(map["GIT_COMMITTER_NAME"], "korp");
1727 assert_eq!(map["GIT_AUTHOR_EMAIL"], "korp@agentmux.local");
1728 assert_eq!(map["GIT_COMMITTER_EMAIL"], "korp@agentmux.local");
1729 }
1730
1731 #[test]
1732 fn lowercases_email_but_preserves_display_name_casing() {
1733 // AGENTMUX_AGENT_ID is natural display casing (e.g. "Korp", per
1734 // SPEC_PR_TITLE_AGENT_HOST_PREFIX_2026_08_22.md's distinction
1735 // between the tag's lowercase machine-key and the title's natural
1736 // casing) -- the git *name* field should read naturally too, but
1737 // the email's local-part must stay lowercase so it can never
1738 // collide with a differently-cased but same agent (git/GitHub
1739 // treat email local-parts as effectively case-sensitive strings
1740 // for linking purposes; we want exactly one canonical email per
1741 // agent regardless of what casing happened to be in the block's
1742 // agentName metadata at spawn time).
1743 let vars = git_identity_env_vars("Korp");
1744 let map: std::collections::HashMap<&str, String> = vars.into_iter().collect();
1745 assert_eq!(map["GIT_AUTHOR_NAME"], "Korp");
1746 assert_eq!(map["GIT_AUTHOR_EMAIL"], "korp@agentmux.local");
1747 }
1748
1749 #[test]
1750 fn distinct_agents_get_distinct_non_colliding_identities() {
1751 let a = git_identity_env_vars("agenty");
1752 let b = git_identity_env_vars("smike");
1753 let a_map: std::collections::HashMap<&str, String> = a.into_iter().collect();
1754 let b_map: std::collections::HashMap<&str, String> = b.into_iter().collect();
1755 assert_ne!(a_map["GIT_AUTHOR_EMAIL"], b_map["GIT_AUTHOR_EMAIL"]);
1756 assert_ne!(a_map["GIT_AUTHOR_NAME"], b_map["GIT_AUTHOR_NAME"]);
1757 }
1758
1759 // ---- identity M0: split_agent_identity_env ---------------------------
1760 //
1761 // Fixture per SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §9.3:
1762 // two agents whose names collide under `derive_slug` ("AgentY" and
1763 // "AGENTY" both lowercase to "agenty"), which `agent_def_insert` gave
1764 // the suffixed slugs `agenty-2` / `agenty-3`. Every assertion below is
1765 // made against that pair so a change that only works for one agent
1766 // cannot pass.
1767
1768 use super::split_agent_identity_env;
1769 use std::collections::HashMap;
1770
1771 fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1772 pairs
1773 .iter()
1774 .map(|(k, v)| (k.to_string(), v.to_string()))
1775 .collect()
1776 }
1777
1778 /// The server launch path (`input.rs` fallback) — `cmd:env` carries
1779 /// nothing identity-shaped, so both must be filled from what the server
1780 /// knows. Positive case first (retro §5): prove they are SET before
1781 /// proving anything about what they are not.
1782 #[test]
1783 fn server_path_sets_both_display_and_slug_from_server_knowledge() {
1784 let mut e = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
1785 split_agent_identity_env(&mut e, "AgentY", Some("agenty-3"));
1786 assert_eq!(e["AGENTMUX_AGENT_DISPLAY"], "AgentY");
1787 assert_eq!(e["AGENTMUX_AGENT_SLUG"], "agenty-3");
1788 }
1789
1790 /// The frontend launch path (`launchAgentDefinition`) already set both
1791 /// to the same values the server holds — nothing changes, nothing is
1792 /// duplicated under a different key.
1793 #[test]
1794 fn frontend_path_with_agreeing_values_is_left_as_is() {
1795 let before = env(&[
1796 ("AGENTMUX_AGENT_ID", "agenty-2"),
1797 ("AGENTMUX_AGENT_SLUG", "agenty-2"),
1798 ("AGENTMUX_AGENT_DISPLAY", "AGENTY"),
1799 ]);
1800 let mut e = before.clone();
1801 split_agent_identity_env(&mut e, "AGENTY", Some("agenty-2"));
1802 assert_eq!(e, before);
1803 }
1804
1805 /// §9.4: this phase must not change what `AGENTMUX_AGENT_ID` holds on
1806 /// either path. Slug-valued on the frontend path, name-valued on the
1807 /// server path — both survive byte-for-byte.
1808 #[test]
1809 fn agentmux_agent_id_is_never_touched_on_either_path() {
1810 let mut frontend = env(&[("AGENTMUX_AGENT_ID", "agenty-2")]);
1811 split_agent_identity_env(&mut frontend, "AGENTY", Some("agenty-2"));
1812 assert_eq!(frontend["AGENTMUX_AGENT_ID"], "agenty-2");
1813
1814 let mut server = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
1815 split_agent_identity_env(&mut server, "AgentY", Some("agenty-3"));
1816 assert_eq!(server["AGENTMUX_AGENT_ID"], "AgentY");
1817
1818 let mut none = env(&[]);
1819 split_agent_identity_env(&mut none, "AgentY", Some("agenty-3"));
1820 assert!(
1821 !none.contains_key("AGENTMUX_AGENT_ID"),
1822 "must not invent one either"
1823 );
1824 }
1825
1826 /// The server's knowledge wins over a `cmd:env` snapshot: a rename after
1827 /// launch moves `block.meta["agentName"]` but not the frozen env copy,
1828 /// and the persisted slug is the collision-resolved one, not whatever
1829 /// the frontend computed before the row existed.
1830 #[test]
1831 fn server_values_override_stale_cmd_env_snapshots() {
1832 let mut e = env(&[
1833 ("AGENTMUX_AGENT_DISPLAY", "AgentY"),
1834 ("AGENTMUX_AGENT_SLUG", "agenty"),
1835 ]);
1836 split_agent_identity_env(&mut e, "AgentY (renamed)", Some("agenty-3"));
1837 assert_eq!(e["AGENTMUX_AGENT_DISPLAY"], "AgentY (renamed)");
1838 assert_eq!(e["AGENTMUX_AGENT_SLUG"], "agenty-3");
1839 }
1840
1841 /// A quick-launch pane (`launchAgent`, provider key as `agentId`) has no
1842 /// `db_agents` row and therefore no slug. The display name is still set;
1843 /// the slug is NOT derived from it — that derivation is the lossy step
1844 /// spec §1.1 identifies as the defect. A `cmd:env` slug survives as the
1845 /// fallback.
1846 #[test]
1847 fn no_persisted_row_sets_display_only_and_keeps_any_cmd_env_slug() {
1848 let mut bare = env(&[]);
1849 split_agent_identity_env(&mut bare, "AgentY", None);
1850 assert_eq!(bare["AGENTMUX_AGENT_DISPLAY"], "AgentY");
1851 assert!(!bare.contains_key("AGENTMUX_AGENT_SLUG"));
1852
1853 let mut with_fallback = env(&[("AGENTMUX_AGENT_SLUG", "agenty-3")]);
1854 split_agent_identity_env(&mut with_fallback, "AgentY", None);
1855 assert_eq!(with_fallback["AGENTMUX_AGENT_SLUG"], "agenty-3");
1856 }
1857
1858 /// An empty or whitespace display name (a pane with no `agentName` meta
1859 /// yet) sets nothing and does not clobber a `cmd:env` value with "".
1860 #[test]
1861 fn empty_display_name_sets_nothing_and_preserves_cmd_env() {
1862 let mut bare = env(&[]);
1863 split_agent_identity_env(&mut bare, " ", None);
1864 assert!(!bare.contains_key("AGENTMUX_AGENT_DISPLAY"));
1865
1866 let mut with_fallback = env(&[("AGENTMUX_AGENT_DISPLAY", "AgentY")]);
1867 split_agent_identity_env(&mut with_fallback, "", Some(" "));
1868 assert_eq!(with_fallback["AGENTMUX_AGENT_DISPLAY"], "AgentY");
1869 assert!(!with_fallback.contains_key("AGENTMUX_AGENT_SLUG"));
1870 }
1871
1872 /// The whole point: after the split, the two colliding agents are
1873 /// distinguishable by BOTH variables, whereas anything derived from the
1874 /// display name alone is not.
1875 #[test]
1876 fn colliding_names_stay_separable_after_the_split() {
1877 let mut a = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
1878 let mut b = env(&[("AGENTMUX_AGENT_ID", "AGENTY")]);
1879 split_agent_identity_env(&mut a, "AgentY", Some("agenty-3"));
1880 split_agent_identity_env(&mut b, "AGENTY", Some("agenty-2"));
1881
1882 assert_ne!(a["AGENTMUX_AGENT_SLUG"], b["AGENTMUX_AGENT_SLUG"]);
1883 assert_ne!(a["AGENTMUX_AGENT_DISPLAY"], b["AGENTMUX_AGENT_DISPLAY"]);
1884 // …and the reason the slug has to come from the store rather than
1885 // from the name: lowercased, the names are the same string.
1886 assert_eq!(
1887 a["AGENTMUX_AGENT_DISPLAY"].to_lowercase(),
1888 b["AGENTMUX_AGENT_DISPLAY"].to_lowercase(),
1889 );
1890 }
1891
1892 // ---- identity M1: carry_agent_uid_env --------------------------------
1893
1894 use super::{carry_agent_uid_env, PersistedAgentIdentity};
1895
1896 fn identity(uid: &str, token: Option<&str>) -> PersistedAgentIdentity {
1897 PersistedAgentIdentity {
1898 uid: uid.to_string(),
1899 slug: Some("agenty-3".to_string()),
1900 token: token.map(str::to_string),
1901 }
1902 }
1903
1904 /// Positive case first: a known row carries both values into the env.
1905 #[test]
1906 fn carry_sets_uid_and_token_from_the_row() {
1907 let mut e = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
1908 carry_agent_uid_env(&mut e, Some(&identity("4f3c-a91", Some("tok-a"))));
1909 assert_eq!(e["AGENTMUX_AGENT_UID"], "4f3c-a91");
1910 assert_eq!(e["AGENTMUX_AGENT_TOKEN"], "tok-a");
1911 assert_eq!(e["AGENTMUX_AGENT_ID"], "AgentY", "§9.4: untouched");
1912 }
1913
1914 /// Reserved-variable rule: a persisted `cmd:env` carrying ANOTHER
1915 /// agent's UID and token (pane reuse, copied block) is overwritten, not
1916 /// respected. For the token this is the difference between an agent
1917 /// holding its own credential and holding someone else's.
1918 #[test]
1919 fn carry_overwrites_stale_cmd_env_values_unconditionally() {
1920 let mut e = env(&[
1921 ("AGENTMUX_AGENT_UID", "9b2e-7d4"),
1922 ("AGENTMUX_AGENT_TOKEN", "tok-of-the-other-agent"),
1923 ]);
1924 carry_agent_uid_env(&mut e, Some(&identity("4f3c-a91", Some("tok-a"))));
1925 assert_eq!(e["AGENTMUX_AGENT_UID"], "4f3c-a91");
1926 assert_eq!(e["AGENTMUX_AGENT_TOKEN"], "tok-a");
1927 }
1928
1929 /// No row (quick-launch pane, lookup failure): both are REMOVED. Leaving
1930 /// a stale token in place would be worse than having none, and the
1931 /// server has nothing truthful to put there.
1932 #[test]
1933 fn carry_removes_both_when_the_server_knows_no_identity() {
1934 let mut e = env(&[
1935 ("AGENTMUX_AGENT_ID", "AgentY"),
1936 ("AGENTMUX_AGENT_UID", "stale"),
1937 ("AGENTMUX_AGENT_TOKEN", "stale"),
1938 ]);
1939 carry_agent_uid_env(&mut e, None);
1940 assert!(!e.contains_key("AGENTMUX_AGENT_UID"));
1941 assert!(!e.contains_key("AGENTMUX_AGENT_TOKEN"));
1942 assert_eq!(e["AGENTMUX_AGENT_ID"], "AgentY");
1943 }
1944
1945 /// A row whose token could not be minted still carries its UID, and a
1946 /// stale token is removed rather than kept — never a half-truth.
1947 #[test]
1948 fn carry_with_uid_but_no_token_sets_uid_and_removes_token() {
1949 let mut e = env(&[("AGENTMUX_AGENT_TOKEN", "stale")]);
1950 carry_agent_uid_env(&mut e, Some(&identity("4f3c-a91", None)));
1951 assert_eq!(e["AGENTMUX_AGENT_UID"], "4f3c-a91");
1952 assert!(!e.contains_key("AGENTMUX_AGENT_TOKEN"));
1953 }
1954
1955 // ---- identity M0/M1: persisted_agent_identity against a real store ---
1956
1957 use super::persisted_agent_identity;
1958 use crate::backend::storage::agents::AgentDefinition;
1959 use crate::backend::storage::store::Store;
1960
1961 fn agent_def(id: &str, name: &str) -> AgentDefinition {
1962 AgentDefinition {
1963 conversation_visibility:
1964 crate::backend::storage::agents::default_conversation_visibility(),
1965 id: id.to_string(),
1966 // Empty on purpose: `agent_def_insert` derives it from `name`
1967 // and collision-resolves, exactly as the launch flow does.
1968 slug: String::new(),
1969 name: name.to_string(),
1970 icon: "✦".to_string(),
1971 provider: "claude".to_string(),
1972 description: String::new(),
1973 working_directory: String::new(),
1974 shell: String::new(),
1975 provider_flags: String::new(),
1976 auto_start: 0,
1977 restart_on_crash: 0,
1978 idle_timeout_minutes: 0,
1979 created_at: 0,
1980 agent_type: "host".to_string(),
1981 environment: String::new(),
1982 agent_bus_id: String::new(),
1983 is_seeded: 0,
1984 accounts: String::new(),
1985 parent_id: String::new(),
1986 branch_label: String::new(),
1987 updated_at: 0,
1988 user_hidden: 0,
1989 container_image: String::new(),
1990 container_volumes: "[]".to_string(),
1991 container_name: String::new(),
1992 use_ambient_login: 0,
1993 auto_continue_enabled: 0,
1994 model_vendor_base_url: String::new(),
1995 memory_id: String::new(),
1996 }
1997 }
1998
1999 fn block_with_agent_id(store: &Store, block_id: &str, agent_id: &str) {
2000 let mut block = crate::backend::obj::Block {
2001 oid: block_id.to_string(),
2002 parentoref: String::new(),
2003 version: 0,
2004 runtimeopts: None,
2005 stickers: None,
2006 meta: {
2007 let mut m = crate::backend::obj::MetaMapType::new();
2008 m.insert("view".to_string(), serde_json::json!("agent"));
2009 m.insert("agentId".to_string(), serde_json::json!(agent_id));
2010 m
2011 },
2012 subblockids: None,
2013 };
2014 store.insert(&mut block).unwrap();
2015 }
2016
2017 /// The §1.1 scenario end to end: two agents whose names collide under
2018 /// `derive_slug` get `agenty` and `agenty-2` from `agent_def_insert`, and
2019 /// the lookup returns each block's OWN row — its UID, its suffixed slug
2020 /// (which no derivation from its name could produce), and a token that
2021 /// is that row's and nobody else's.
2022 #[test]
2023 fn persisted_identity_returns_the_rows_uid_slug_and_own_token() {
2024 let store = Store::open_in_memory().unwrap();
2025 let mut first = agent_def("uid-first", "AgentY");
2026 store.agent_def_insert(&mut first).unwrap();
2027 let mut second = agent_def("uid-second", "AGENTY");
2028 store.agent_def_insert(&mut second).unwrap();
2029 assert_eq!(
2030 first.slug, "agenty",
2031 "fixture: first insert keeps the bare slug"
2032 );
2033 assert_eq!(
2034 second.slug, "agenty-2",
2035 "fixture: second insert is suffixed"
2036 );
2037
2038 block_with_agent_id(&store, "block-first", "uid-first");
2039 block_with_agent_id(&store, "block-second", "uid-second");
2040
2041 let a = persisted_agent_identity(&store, "block-first").expect("first resolves");
2042 let b = persisted_agent_identity(&store, "block-second").expect("second resolves");
2043 assert_eq!(a.uid, "uid-first");
2044 assert_eq!(b.uid, "uid-second");
2045 assert_eq!(a.slug.as_deref(), Some("agenty"));
2046 assert_eq!(b.slug.as_deref(), Some("agenty-2"));
2047
2048 // The token is the persisted one for that UID, minted on this first
2049 // spawn, and distinct between the two colliding agents.
2050 let tok_a = a.token.clone().expect("token minted");
2051 let tok_b = b.token.clone().expect("token minted");
2052 assert_ne!(tok_a, tok_b);
2053 assert_eq!(
2054 store.agent_token_load("uid-first").unwrap().as_deref(),
2055 Some(tok_a.as_str())
2056 );
2057 assert_eq!(
2058 store.agent_token_load("uid-second").unwrap().as_deref(),
2059 Some(tok_b.as_str())
2060 );
2061
2062 // A second spawn of the same agent carries the SAME token — long-lived
2063 // for the agent's lifetime (spec §6.3), not re-minted per spawn.
2064 let a_again = persisted_agent_identity(&store, "block-first").unwrap();
2065 assert_eq!(a_again.token.as_deref(), Some(tok_a.as_str()));
2066 }
2067
2068 /// Identity M4b-2: the helper App Server and ACP use carries the
2069 /// block's row UID and token into their own env, overwriting a stale
2070 /// token, and strips both for a block with no row — outside a runtime
2071 /// and, through `block_in_place`, on a multi-thread runtime worker.
2072 #[test]
2073 fn carry_agent_workdir_env_is_the_launch_cwd_for_host_agents_only() {
2074 let meta = |pairs: &[(&str, &str)]| -> crate::backend::obj::MetaMapType {
2075 pairs.iter().map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string()))).collect()
2076 };
2077 let stale = || {
2078 std::collections::HashMap::from([("AGENTMUX_AGENT_WORKDIR".to_string(), "C:/stale".to_string())])
2079 };
2080
2081 let workdir = |e: &std::collections::HashMap<String, String>| std::path::PathBuf::from(&e["AGENTMUX_AGENT_WORKDIR"]);
2082 let ws = std::env::temp_dir().join("clamk-0612a");
2083 let ws_str = ws.to_string_lossy().into_owned();
2084
2085 for mode in ["host", "standalone"] {
2086 let mut e = stale();
2087 super::carry_agent_workdir_env(&mut e, &meta(&[("agentMode", mode), ("cmd:cwd", &ws_str)]));
2088 assert_eq!(workdir(&e), ws, "{mode}");
2089 }
2090 // No agentMode is a host agent, srv's default.
2091 let mut e = stale();
2092 super::carry_agent_workdir_env(&mut e, &meta(&[("cmd:cwd", &ws_str)]));
2093 assert_eq!(workdir(&e), ws);
2094
2095 // Exported as the absolute path the process starts in: a relative
2096 // cwd resolves against srv's own cwd, `~` against the home dir.
2097 let mut e = stale();
2098 super::carry_agent_workdir_env(&mut e, &meta(&[("cmd:cwd", "projects/foo")]));
2099 assert_eq!(workdir(&e), std::env::current_dir().unwrap().join("projects/foo"));
2100 if let Some(home) = dirs::home_dir() {
2101 let mut e = stale();
2102 super::carry_agent_workdir_env(&mut e, &meta(&[("cmd:cwd", "~/.agentmux/agents/x-0930a")]));
2103 assert_eq!(workdir(&e), home.join(".agentmux/agents/x-0930a"));
2104 }
2105
2106 let mut e = stale();
2107 super::carry_agent_workdir_env(&mut e, &meta(&[("agentMode", "container"), ("cmd:cwd", &ws_str)]));
2108 assert!(!e.contains_key("AGENTMUX_AGENT_WORKDIR"), "container");
2109 let mut e = stale();
2110 super::carry_agent_workdir_env(&mut e, &meta(&[("agentMode", "host"), ("cmd:cwd", " ")]));
2111 assert!(!e.contains_key("AGENTMUX_AGENT_WORKDIR"), "no cwd");
2112 }
2113
2114 #[test]
2115 fn carry_block_identity_env_carries_or_strips() {
2116 let store = Store::open_in_memory().unwrap();
2117 let mut def = agent_def("uid-carry", "Carrier");
2118 store.agent_def_insert(&mut def).unwrap();
2119 block_with_agent_id(&store, "block-carry", "uid-carry");
2120 block_with_agent_id(&store, "block-carry-none", "claude");
2121
2122 let mut env = std::collections::HashMap::from([(
2123 "AGENTMUX_AGENT_TOKEN".to_string(),
2124 "stale-token".to_string(),
2125 )]);
2126 super::carry_block_identity_env(&store, "block-carry", &mut env);
2127 assert_eq!(
2128 env.get("AGENTMUX_AGENT_UID").map(String::as_str),
2129 Some("uid-carry")
2130 );
2131 assert_eq!(
2132 env.get("AGENTMUX_AGENT_TOKEN"),
2133 store.agent_token_load("uid-carry").unwrap().as_ref()
2134 );
2135
2136 let mut none = env.clone();
2137 super::carry_block_identity_env(&store, "block-carry-none", &mut none);
2138 assert!(
2139 !none.contains_key("AGENTMUX_AGENT_UID") && !none.contains_key("AGENTMUX_AGENT_TOKEN")
2140 );
2141
2142 let rt = tokio::runtime::Builder::new_multi_thread()
2143 .worker_threads(1)
2144 .build()
2145 .unwrap();
2146 let store = std::sync::Arc::new(store);
2147 let on_worker = rt.block_on({
2148 let store = store.clone();
2149 async move {
2150 tokio::spawn(async move {
2151 let mut env = std::collections::HashMap::new();
2152 super::carry_block_identity_env(&store, "block-carry", &mut env);
2153 env
2154 })
2155 .await
2156 .unwrap()
2157 }
2158 });
2159 assert_eq!(
2160 on_worker.get("AGENTMUX_AGENT_UID").map(String::as_str),
2161 Some("uid-carry")
2162 );
2163 }
2164
2165 /// A quick-launch pane carries a provider key as `agentId` and has no
2166 /// `db_agents` row; a block that does not exist has nothing either.
2167 /// Both are "no identity", never an error and never a derived value —
2168 /// and, load-bearing for the token: no row means no token is minted.
2169 #[test]
2170 fn persisted_identity_is_none_for_quick_launch_panes_and_missing_blocks() {
2171 let store = Store::open_in_memory().unwrap();
2172 block_with_agent_id(&store, "block-quick", "claude");
2173 assert_eq!(persisted_agent_identity(&store, "block-quick"), None);
2174 assert_eq!(persisted_agent_identity(&store, "block-missing"), None);
2175 assert_eq!(
2176 store.agent_token_load("claude").unwrap(),
2177 None,
2178 "no token minted for a provider key"
2179 );
2180 }
2181
2182 /// Codex P1 on #3591: the builder re-checks deletion at its last read —
2183 /// an agent deleted after the gate's lookup (no row resolves now, and the
2184 /// shared registry holds only a retired record) is reported, so the spawn
2185 /// is refused; a live agent is not.
2186 #[test]
2187 fn the_builder_recheck_reports_an_agent_deleted_after_the_gate() {
2188 let tmp = tempfile::tempdir().unwrap();
2189 let store = Store::open_in_memory().unwrap();
2190 let defs = crate::registry::DefinitionStore::open(tmp.path().join("definitions")).unwrap();
2191 store.set_def_registry(std::sync::Arc::new(defs));
2192 let mut def = agent_def("uid-late-del", "LateDel");
2193 store.agent_def_insert(&mut def).unwrap();
2194 block_with_agent_id(&store, "block-late-del", "uid-late-del");
2195 let live = persisted_agent_identity(&store, "block-late-del");
2196 assert!(live.is_some());
2197 assert_eq!(
2198 super::deleted_agent_of_rowless_block(&store, "block-late-del", live.as_ref()),
2199 None
2200 );
2201
2202 assert!(store.agent_def_delete("uid-late-del").unwrap());
2203 let after = persisted_agent_identity(&store, "block-late-del");
2204 assert_eq!(after, None);
2205 assert_eq!(
2206 super::deleted_agent_of_rowless_block(&store, "block-late-del", after.as_ref())
2207 .as_deref(),
2208 Some("uid-late-del")
2209 );
2210 }
2211
2212 /// Spec §6.3: revoked on deletion. Deleting the agent through either
2213 /// deletion entry point takes its token with it, so a later spawn on a
2214 /// reused block cannot resurrect the old credential.
2215 #[test]
2216 fn deleting_the_agent_revokes_its_token() {
2217 let store = Store::open_in_memory().unwrap();
2218 let mut def = agent_def("uid-del", "Deleted");
2219 store.agent_def_insert(&mut def).unwrap();
2220 block_with_agent_id(&store, "block-del", "uid-del");
2221 let minted = persisted_agent_identity(&store, "block-del").unwrap().token;
2222 assert!(minted.is_some());
2223
2224 assert!(store.agent_def_delete("uid-del").unwrap());
2225 assert_eq!(store.agent_token_load("uid-del").unwrap(), None);
2226 assert_eq!(persisted_agent_identity(&store, "block-del"), None);
2227
2228 // …and via the launch-side entry point too.
2229 let mut def2 = agent_def("uid-del2", "Deleted2");
2230 store.agent_def_insert(&mut def2).unwrap();
2231 block_with_agent_id(&store, "block-del2", "uid-del2");
2232 assert!(persisted_agent_identity(&store, "block-del2")
2233 .unwrap()
2234 .token
2235 .is_some());
2236 assert!(store.instance_delete("uid-del2").unwrap());
2237 assert_eq!(store.agent_token_load("uid-del2").unwrap(), None);
2238 }
2239
2240 #[test]
2241 fn email_domain_is_not_a_real_github_verifiable_domain() {
2242 // Load-bearing for the fix's safety property: this must NOT be a
2243 // domain GitHub could ever link to a real account (see the
2244 // git_identity_env_vars doc comment) -- asserting the exact
2245 // domain here so a future edit can't accidentally change it to
2246 // something real (e.g. "users.noreply.github.com") without this
2247 // test catching it.
2248 let vars = git_identity_env_vars("camper");
2249 let map: std::collections::HashMap<&str, String> = vars.into_iter().collect();
2250 assert!(map["GIT_AUTHOR_EMAIL"].ends_with("@agentmux.local"));
2251 assert!(map["GIT_COMMITTER_EMAIL"].ends_with("@agentmux.local"));
2252 }
2253}