agentmux_srv/server/app_api/mod.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxAPI — high-level commands for programmatic control of AgentMux.
5//!
6//! These commands orchestrate multiple low-level operations (CreateBlock, SetMeta,
7//! ControllerResync) behind stable, intent-based interfaces. Callers express what
8//! they want ("open an agent pane with AgentX"), not how to do it.
9
10use std::sync::Arc;
11
12use base64::Engine;
13use serde_json::json;
14
15use crate::backend::blockcontroller;
16use crate::backend::obj::{self, Block, Tab, Workspace, MetaMapType};
17use crate::backend::providers;
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::rpc_types::*;
20use crate::backend::session_archive;
21use crate::backend::storage::store::{Store, AgentContent, AgentDefinition, AgentInstance};
22use crate::backend::storage::identities::IdentityAccount;
23use crate::backend::storage::bundles::Bundle;
24
25use super::AppState;
26use crate::server::cli_handlers::resolve_provider_cli_on_path;
27
28mod agent_open;
29/// Re-exported for `server/mod.rs`'s `POST /api/v1/agent/open` handler (the
30/// `OpenAgent` MCP tool's backing route) — the HTTP surface must share the
31/// RPC path's exact implementation, including its AGENT_OPEN_LOCKS TOCTOU
32/// serialization. See REPORT_AGENT_OPEN_API_GAP_2026_09_06.md.
33pub(crate) use agent_open::open_agent_impl;
34// pub(crate): `server/mod.rs`'s `/agentmux/agent/stop` handler (the
35// cross-channel bulk-stop forward target,
36// SPEC_FLEET_BULK_STOP_CROSS_CHANNEL_2026_08_22.md) calls
37// `stop_one_agent_block` directly — same function `fleet_bulk_stop_impl`
38// already uses for a LOCAL target, reused rather than duplicated for a
39// forwarded one.
40pub(crate) mod agent_io;
41mod agent_define;
42/// Re-exported so the human-facing creation/edit RPC handlers
43/// (`server::agent_handlers::template`/`core`) can reuse the same
44/// vendor-base-url validation `agent.define` already uses, instead of
45/// duplicating it.
46pub(crate) use agent_define::{check_provider_unchanged, validate_vendor_base_url};
47// pub(crate): `server/mod.rs`'s `POST /api/v1/agent/pane/close` handler (the
48// `ClosePane` MCP tool's backing route) calls `pane::handle_close_pane`
49// directly, same as `fleet`'s exposure just below.
50pub(crate) mod pane;
51// pub(crate): `server/mod.rs`'s `POST /api/v1/agent/dev_server/register`
52// handler (the `RegisterDevServer` MCP tool's backing route) calls
53// `dev_server::handle_register_dev_server` directly, same pattern as
54// `pane`'s `handle_close_pane` just above.
55pub(crate) mod dev_server;
56// pub(crate): `agentmux-mcp doctor`'s routes, in `routes.rs`.
57pub(crate) mod mcp_health;
58// pub(crate): the viewer feed reads transcripts through `read_range`.
59pub(crate) mod blockfile;
60pub(crate) mod session;
61mod identity;
62pub(crate) mod bundle;
63mod memory;
64mod global_memory;
65// MuxAPI bodies live with the submodule that registers them; these
66// re-exports keep every `app_api::…` path unchanged.
67pub use pane::{open_pane};
68pub(crate) use pane::{move_tab};
69pub use agent_define::{allocate_agent_workdir};
70pub(crate) use agent_define::{agent_define_core};
71pub(crate) use identity::{identity_self_accounts_impl, identity_account_validate_stored_impl};
72pub(crate) use bundle::{bundle_list_impl, bundle_get_impl, bundle_self_get_impl};
73pub(crate) use bundle::SelfOwner;
74pub(crate) use global_memory::GlobalMemoryWriteProvenance;
75pub(crate) use memory::MemoryWriteProvenance;
76pub(crate) use memory::{memory_list_impl, memory_read_impl, memory_write_impl, memory_history_impl, memory_diff_impl, memory_revert_impl};
77pub(crate) use global_memory::{global_memory_write_impl, global_memory_list_impl, global_memory_read_impl, global_memory_remove_impl, global_memory_history_impl, global_memory_diff_impl, bundle_version_diff, global_memory_revert_impl};
78mod skill;
79mod mcp;
80mod bookmarks;
81mod browser_profiles;
82pub(crate) use browser_profiles::list as browser_profiles_list;
83mod layout;
84mod browser_start_page;
85mod voice;
86pub(crate) mod fleet;
87mod attachments;
88pub(crate) mod connections;
89pub(crate) mod viewer;
90mod presence;
91pub(crate) mod tower_pane;
92mod blockstats;
93mod shells;
94
95/// Register all MuxAPI handlers on the RPC engine.
96pub fn register_app_api_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
97 agent_open::register(engine, state);
98 agent_io::register(engine, state);
99 agent_define::register(engine, state);
100 pane::register(engine, state);
101 blockfile::register(engine, state);
102 session::register(engine, state);
103 identity::register(engine, state);
104 bundle::register(engine, state);
105 memory::register(engine, state);
106 skill::register(engine, state);
107 mcp::register(engine, state);
108 bookmarks::register(engine, state);
109 browser_profiles::register(engine, state);
110 layout::register(engine, state);
111 browser_start_page::register(engine, state);
112 voice::register(engine, state);
113 fleet::register(engine, state);
114 attachments::register(engine, state);
115 connections::register(engine, state);
116 viewer::register(engine, state);
117 presence::register(engine, state);
118 tower_pane::register(engine, state);
119 blockstats::register(engine, state);
120 shells::register(engine, state);
121}
122
123#[cfg(test)]
124#[path = "tests/global_memory_impl_tests.rs"]
125mod global_memory_impl_tests;
126#[cfg(test)]
127#[path = "tests/global_memory_version_impl_tests.rs"]
128mod global_memory_version_impl_tests;
129#[cfg(test)]
130#[path = "tests/memory_version_impl_tests.rs"]
131mod memory_version_impl_tests;
132
133// ---------------------------------------------------------------------------
134// Shared helpers used by submodules (via `use super::*`)
135// ---------------------------------------------------------------------------
136/// If the agent's GLOBAL transcript zone (`agent:<defId>:current`) has
137/// `output`, return `(global_store, agent_zone)` — preferred even when this
138/// channel's local `output` is non-empty. Returns `None` when the block isn't
139/// agent-anchored or is archived, there's no global store, or the global zone
140/// is empty — callers then read the per-channel store keyed by `block_id`.
141///
142/// Only the agent `output` stream is globalized; every other file stays local.
143pub(super) fn global_output_source(
144 _per_channel: &Arc<crate::backend::storage::filestore::FileStore>,
145 global: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
146 mstore: &Arc<crate::backend::storage::store::Store>,
147 block_id: &str,
148 filename: &str,
149) -> Option<(Arc<crate::backend::storage::filestore::FileStore>, String)> {
150 if filename != crate::backend::agent_session::OUTPUT_FILE {
151 return None;
152 }
153 let gfs = global.as_ref()?;
154 let block = mstore.get::<Block>(block_id).ok().flatten()?;
155 let archived = block
156 .meta
157 .get(crate::backend::session_archive::META_SESSION_ARCHIVED_AT)
158 .and_then(|v| v.as_i64())
159 .map(|v| v > 0)
160 .unwrap_or(false);
161 if archived {
162 return None;
163 }
164 let zone = crate::backend::agent_session::agent_zone_for_block_meta(&block.meta)?;
165 match gfs.stat(&zone, filename) {
166 Ok(Some(ref wf)) if wf.size > 0 => Some((gfs.clone(), zone)),
167 _ => None,
168 }
169}
170
171/// Exact non-blank line count of the `output` file in `zone`, computed via the
172/// same streaming index builder `read_range` uses (so the two endpoints always
173/// agree). Returns `Some(0)` for an empty file, `None` on read failure.
174///
175/// Reuses `output.idx` when its `covered_size` header already matches the
176/// current `output` size — same freshness check
177/// `register_blockfile_read_range` (`blockfile.rs`) already does — instead of
178/// unconditionally rescanning the entire history on every call. Without this,
179/// every pane open for an agent with any global-zone history pays a full
180/// O(history size) rescan even when nothing has changed since the index was
181/// last built.
182pub(super) fn global_zone_line_count(
183 gfs: &Arc<crate::backend::storage::filestore::FileStore>,
184 zone: &str,
185) -> Option<u64> {
186 use crate::backend::blockcontroller::shell::{extend_output_idx, output_index};
187
188 // `output` and its index from one database snapshot: sizes, header and
189 // generation label of the same moment, whatever another srv instance is
190 // writing to this shared zone (Codex on #3634).
191 let view = output_index(gfs, zone)?;
192 if view.output_size == 0 {
193 return Some(0);
194 }
195 if let Some(lines) = view.fresh_lines {
196 return Some(lines);
197 }
198
199 // Stale or missing. `extend_output_idx` scans only the appended bytes when
200 // it can anchor on the existing index, and falls back to a full rebuild
201 // when it can't (missing, unreadable, no entries, another generation's, or
202 // `output` shrank).
203 //
204 // The count must stay EXACT. Returning a stale one instead was tried and
205 // is wrong: this feeds `useHistoryPagination`'s tail window
206 // (`offset = total - PAGE_SIZE`), so an undercount silently drops the most
207 // recent history on reopen — codex P1 on PR #2838. Only the COST of
208 // keeping it exact was ever negotiable, which is what the incremental scan
209 // buys: `output` grows continuously for a live agent, so "stale" is the
210 // steady state, and a 30-second line-count poll was driving a full
211 // O(file-size) rescan every time — 37 rebuilds in 19 minutes on a 759 MB
212 // transcript, mean 3168 ms, ~10% permanent duty cycle. See
213 // docs/reports/REPORT_AGENT_PANE_LOAD_RENDER_ARCHITECTURE_2026_08_27.md §5.
214 extend_output_idx(gfs, zone)
215}
216
217/// Resolve a tab ID: use the provided one, or fall back to the first workspace's active tab.
218pub(super) fn resolve_tab_id(mstore: &Store, explicit: Option<&str>) -> Result<String, String> {
219 if let Some(tid) = explicit {
220 return Ok(tid.to_string());
221 }
222
223 // Fall back to first workspace's active tab
224 let workspaces: Vec<Workspace> = mstore.get_all::<Workspace>()
225 .map_err(|e| format!("agent.open: list workspaces: {e}"))?;
226
227 for ws in &workspaces {
228 if !ws.activetabid.is_empty() {
229 return Ok(ws.activetabid.clone());
230 }
231 if let Some(first_tab) = ws.tabids.first() {
232 return Ok(first_tab.clone());
233 }
234 }
235
236 Err("no tabs found in any workspace".to_string())
237}
238
239/// Find an existing agent block in a tab by agent ID.
240pub(super) fn find_agent_block(mstore: &Store, tab_id: &str, agent_id: &str) -> Result<Option<Block>, String> {
241 let tab: Tab = mstore.must_get(tab_id)
242 .map_err(|e| format!("TAB_NOT_FOUND: {e}"))?;
243
244 for block_id in &tab.blockids {
245 if let Ok(Some(block)) = mstore.get::<Block>(block_id) {
246 let block_agent_id = obj::meta_get_string(&block.meta, "agentId", "");
247 if block_agent_id == agent_id {
248 return Ok(Some(block));
249 }
250 }
251 }
252 Ok(None)
253}
254
255/// Find which tab a given block currently belongs to, by scanning every
256/// tab's `blockids`. Needed because `resolve_tab_id` only resolves an
257/// explicit tab_id or "the active tab" — neither answers "which tab is MY
258/// OWN block in," which a caller passing its own block id (e.g. an MCP
259/// tool's `split_reference_block_id`) actually needs.
260pub(super) fn resolve_tab_id_for_block(mstore: &Store, block_id: &str) -> Result<String, String> {
261 let tabs: Vec<Tab> = mstore.get_all::<Tab>()
262 .map_err(|e| format!("resolve_tab_id_for_block: list tabs: {e}"))?;
263 for tab in tabs {
264 if tab.blockids.iter().any(|b| b == block_id) {
265 return Ok(tab.oid);
266 }
267 }
268 Err(format!("resolve_tab_id_for_block: block {block_id} not found in any tab"))
269}
270
271/// Find an Editor-view block the tab shows ([`shown_block_ids`]: in front
272/// of its stack, not minimized, not hidden behind a magnified pane) on
273/// `connection` (its meta `connection`; absent and `local` are this
274/// computer), if any. Direct sibling of `find_agent_block` above, checking
275/// `meta.view == "editor"` instead of `meta.agentId`. An editor on another
276/// connection is never it: its reads and saves go there (remote terminals
277/// spec §6.3).
278pub(super) fn find_editor_block(mstore: &Store, tab_id: &str, connection: &str) -> Result<Option<Block>, String> {
279 let tab: Tab = mstore.must_get(tab_id)
280 .map_err(|e| format!("TAB_NOT_FOUND: {e}"))?;
281 let Ok(layout) = mstore.must_get::<obj::LayoutState>(&tab.layoutstate) else {
282 return Ok(None);
283 };
284
285 for block_id in shown_block_ids(&layout) {
286 if let Ok(Some(block)) = mstore.get::<Block>(&block_id) {
287 if obj::meta_get_string(&block.meta, "view", "") == "editor"
288 && same_pane_connection(&obj::meta_get_string(&block.meta, "connection", ""), connection)
289 {
290 return Ok(Some(block));
291 }
292 }
293 }
294 Ok(None)
295}
296
297/// The blocks a tab's layout shows: each pane's active member (the tab
298/// in front of its stack), leaving out minimized panes, and every pane
299/// but the magnified one while one is magnified. A file pushed into an
300/// editor the user can't see would look like nothing happened
301/// (SPEC_AGENT_PANE_TOOL_PEEK_ON_LINE_AND_MD_OPEN_IN_EDITOR_2026_10_11.md §2.2).
302pub(super) fn shown_block_ids(layout: &obj::LayoutState) -> Vec<String> {
303 fn walk(node: &agentmux_common::LayoutNode, magnified: &str, out: &mut Vec<String>) {
304 if crate::backend::layout::is_effectively_minimized(node) {
305 return;
306 }
307 if node.children.is_empty() {
308 if let Some(data) = &node.data {
309 if !data.block_id.is_empty() && (magnified.is_empty() || magnified == node.id) {
310 out.push(data.block_id.clone());
311 }
312 }
313 return;
314 }
315 for child in &node.children {
316 walk(child, magnified, out);
317 }
318 }
319 let mut out = Vec::new();
320 if let Some(root) = &layout.rootnode {
321 walk(root, &layout.magnifiednodeid, &mut out);
322 }
323 out
324}
325
326/// Whether two panes' connections are one: absent and `local` are this
327/// computer; otherwise the same host however it is spelled.
328pub(super) fn same_pane_connection(a: &str, b: &str) -> bool {
329 let norm = |c: &str| {
330 let c = c.trim();
331 if c.is_empty() || c == "local" { String::new() } else { c.to_string() }
332 };
333 let (a, b) = (norm(a), norm(b));
334 if a.is_empty() || b.is_empty() {
335 return a == b;
336 }
337 crate::backend::remote::conn::same_connection(&a, &b)
338}
339
340// ---------------------------------------------------------------------------
341// S1 enforcement helper
342// ---------------------------------------------------------------------------
343
344/// S1: the caller may only act on itself.
345///
346/// `ctx.agent_id` is stamped at `bus:register` and is the agent's slug;
347/// `req_agent_id` is whatever the request named. Both are compared **as
348/// canonical `db_agents.id`s** when they are not already byte-identical, so a
349/// caller that authenticates with its slug and names itself by id (or by a
350/// differently-cased slug) is recognised as itself.
351///
352/// **Both sides resolve, or neither does.** Phase 4 of
353/// `SPEC_CANONICAL_AGENT_ID_MIGRATION_2026_09_21.md` §6 proposed stamping the
354/// resolved id into `RpcContext` at registration instead, and flags the hazard
355/// itself: resolving one side of this equality while the other stays a slug
356/// silently denies **every** caller, turning an authz boundary into a
357/// permanent outage that no test of the allow path would catch. Resolving both
358/// here, at the single point of comparison, has no such window — there is no
359/// deploy ordering in which one side has migrated and the other has not.
360///
361/// The byte-equality fast path is deliberate and load-bearing: it is the
362/// overwhelmingly common case, it costs no store lookup, and it makes this
363/// function's behaviour for existing callers *identical* to the pre-migration
364/// version. Resolution only ever runs where the old code would already have
365/// returned `FORBIDDEN`, so this can admit calls that used to be rejected but
366/// can never reject one that used to be admitted.
367///
368/// Admitting more is safe here precisely because resolution is per-input and
369/// fails closed: two different inputs resolve to one id only when they name
370/// one agent, and an ambiguous slug (two agents, one slug) resolves to
371/// `Err` on both sides — so a collision denies rather than granting.
372///
373/// A failed resolution is reported as `agent_id mismatch`, never "no such
374/// agent": whether some other agent exists is not something an unauthorised
375/// caller should be able to probe through this boundary.
376/// Takes the store rather than `&AppState` because the handlers do not agree
377/// on what they capture — some clone the whole state into the closure, others
378/// clone `mstore` alone — and a borrowed `&AppState` cannot escape into a
379/// `'static` future.
380pub(super) fn check_s1(
381 store: &crate::backend::storage::store::Store,
382 ctx: &RpcContext,
383 req_agent_id: &str,
384) -> Result<(), String> {
385 check_s1_resolved(store, &ctx.agent_id, req_agent_id)
386}
387
388/// `check_s1`'s decision over plain strings, so it can be tested without
389/// constructing an `RpcContext`.
390fn check_s1_resolved(
391 store: &crate::backend::storage::store::Store,
392 ctx_agent_id: &str,
393 req_agent_id: &str,
394) -> Result<(), String> {
395 if ctx_agent_id.is_empty() {
396 return Err("FORBIDDEN: unauthenticated agent connection".to_string());
397 }
398 if ctx_agent_id == req_agent_id {
399 return Ok(());
400 }
401 let mismatch = || "FORBIDDEN: agent_id mismatch".to_string();
402 let caller = crate::backend::agent_resolve::resolve_agent_id(store, ctx_agent_id)
403 .map_err(|_| mismatch())?;
404 let target =
405 crate::backend::agent_resolve::resolve_agent_id(store, req_agent_id).map_err(|_| mismatch())?;
406 if caller != target {
407 return Err(mismatch());
408 }
409 Ok(())
410}
411
412/// Resolve an S1-authenticated agent id (the slug — AGENTMUX_AGENT_ID /
413/// bus:register id, e.g. "Agent3") to the agent's DEFINITION id, which is
414/// what `db_agent_identity_links.agent_id` stores (== `AgentDefinition.id`;
415/// see m0013 and `identity/resolver/inject.rs::resolve_bindings_for_instance`).
416///
417/// Every link-table operation reached from MuxAPI must go through
418/// this: MuxAPI callers authenticate with the slug, but writing the slug
419/// into the link table either trips the per-channel schema's
420/// `FOREIGN KEY (agent_id) REFERENCES db_agents(id)` (v30, #3088 — was
421/// `db_agent_definitions(id)`; a definition id still always satisfies it,
422/// since every definition has a `db_agents` mirror at the same id) (loud
423/// "FOREIGN KEY constraint failed" — the id_store fallback path when the
424/// 0011 shared-store backfill hasn't applied), or — on the shared store,
425/// whose links table carries no agent_id FK — silently writes a row keyed
426/// by slug that the resolver (which reads by definition id) can never
427/// match. Reads have the mirror-image bug: listing links by slug always
428/// returns empty, so ownership checks reject and unlinks no-op.
429///
430/// A caller that already holds a definition id passes through unchanged
431/// (verified against `agent_def_get`), so internal non-S1 callers of the
432/// shared `*_impl` helpers stay valid.
433pub(super) fn resolve_agent_definition_id(
434 state: &AppState,
435 agent_id: &str,
436) -> Result<String, String> {
437 // The three tiers this used to spell out inline (db_agents by slug, then
438 // by id, then the registry) are now `agent_resolve`'s, shared with
439 // `resolve_agent_uuid`. The registry tier in particular is load-bearing and
440 // was won the hard way — reagentx P1 on PR #2428 (round 4): the common case
441 // is a live agent with no `db_agents` row at all (launching an agent does
442 // not create one), so `identity.self.accounts`/`IdentityAccounts` stayed
443 // live-broken for registry-only agents even after `instance_get_by_slug`
444 // existed. See `agent_resolve::resolve_agent_id`.
445 crate::backend::agent_resolve::resolve_agent_id(&state.mstore, agent_id)
446}
447
448// Current unix time in milliseconds (0 if the clock is before the epoch).
449pub(super) use agentmux_common::time::now_ms;
450
451/// Parse + validate a saved per-agent `ui:zoom` content blob for seeding a new
452/// agent block's `term:zoom`. Returns `Some(z)` only for a parseable,
453/// non-default (≠ 1.0), in-[0.5, 2.0] zoom (the range the frontend enforces in
454/// term.tsx); anything else (default, out of range, garbage) returns `None` so
455/// the new block opens at the default 1.0. See SPEC_AGENT_ZOOM_PERSISTENCE §4.2.
456pub(super) fn parse_seed_zoom(raw: &str) -> Option<f64> {
457 let z = raw.trim().parse::<f64>().ok()?;
458 if (z - 1.0).abs() > f64::EPSILON && (0.5..=2.0).contains(&z) {
459 Some(z)
460 } else {
461 None
462 }
463}
464
465#[cfg(test)]
466#[path = "tests/cross_channel_tests.rs"]
467mod cross_channel_tests;
468
469
470#[cfg(test)]
471#[path = "tests/pane_open_reducer_tests.rs"]
472mod pane_open_reducer_tests;
473
474#[cfg(test)]
475#[path = "tests/agent_zoom_seed_tests.rs"]
476mod agent_zoom_seed_tests;
477
478#[cfg(test)]
479#[path = "tests/bundle_upsert_input_tests.rs"]
480mod bundle_upsert_input_tests;
481
482// SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md follow-up: `PresetGet`
483// self-mode (backed by `bundle_self_get_impl`) only ever checked the local
484// `db_agents` table via `instance_get_by_slug`. A live agent that only
485// exists in the global named-agent registry (the common case — launching
486// an agent does not create a `db_agents` row, see issue #1836, already
487// handled the same way by `native_memory_handlers::memory_dir_for_agent`)
488// fell through to `instance: None` and silently returned the generic
489// blank/vanilla preset, with nothing to distinguish it from an agent that
490// genuinely has no bundle bound. Confirmed live against a real registry-only
491// agent named "AgentY": `PresetGet` returned `is_blank: true` even though
492// the registry's own `memory_id` was set.
493#[cfg(test)]
494#[path = "tests/bundle_self_get_registry_fallback_tests.rs"]
495mod bundle_self_get_registry_fallback_tests;
496
497#[cfg(test)]
498#[path = "tests/identity_self_accounts_tests.rs"]
499mod identity_self_accounts_tests;
500
501#[cfg(test)]
502#[path = "tests/s1_tests.rs"]
503mod s1_tests;
504
505#[cfg(test)]
506#[path = "tests/self_owner_tests.rs"]
507mod self_owner_tests;
508
509#[cfg(test)]
510#[path = "tests/agent_io_open_wire_tests.rs"]
511mod agent_io_open_wire_tests;