Skip to main content

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;