Skip to main content

agentmux_common/
api_types.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Canonical HTTP request/response DTOs shared between agentmux-srv,
5//! agentmux-mcp, and agentmux-bashwrap.  Every struct here is the single
6//! source of truth for the corresponding wire shape; the three crates all
7//! import rather than redeclare these types.
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12fn is_zero(n: &usize) -> bool {
13    *n == 0
14}
15
16// ── MPS publish ───────────────────────────────────────────────────────────────
17
18/// `POST /agentmux/wps/publish` — shared client/server envelope.
19///
20/// Sent by `agentmux-bashwrap` and received by `agentmux-srv`.
21/// Mirrors `MuxEvent` but omits the server-populated `sender` field.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WpsPublishRequest {
24    pub event: String,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub scopes: Vec<String>,
27    #[serde(default, skip_serializing_if = "is_zero")]
28    pub persist: usize,
29    pub data: serde_json::Value,
30}
31
32// ── Shell ─────────────────────────────────────────────────────────────────────
33
34/// `POST /api/v1/shell/create`
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ShellCreateRequest {
37    pub agent_block_id: String,
38    pub cmd: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub cwd: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub title: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub env: Option<HashMap<String, String>>,
45    /// If true, pipe the child's stdin so ShellInput() can write to it.
46    /// Default false (stdin is /dev/null) — avoids blocking programs that
47    /// read stdin to EOF (e.g. `cat` with no args).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub capture_stdin: Option<bool>,
50    /// Where the command runs: `local` (the default) or `wsl://<distro>`. An
51    /// SSH destination is refused until SSH terminals ship
52    /// (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md §8).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub connection: Option<String>,
55    /// The calling agent's signed identity (`UiAutomationAuth`, its own jekt
56    /// key). Required for an SSH `connection`: srv derives the agent's pane
57    /// from it and refuses a request naming another pane, so no agent can
58    /// borrow another's SSH consent.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub auth: Option<UiAutomationAuth>,
61}
62
63/// Response from `POST /api/v1/shell/create`
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ShellCreateResponse {
66    pub shell_id: String,
67}
68
69/// `POST /api/v1/shell/stop`
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ShellStopRequest {
72    pub shell_id: String,
73}
74
75/// Response from `POST /api/v1/shell/stop`
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ShellStopResponse {
78    pub stopped: bool,
79}
80
81/// `POST /api/v1/shell/input` — write text to a running shell's stdin.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ShellInputRequest {
84    pub shell_id: String,
85    /// Text to write. A newline is appended automatically so single answers
86    /// like "y" work without the caller knowing the line discipline.
87    pub text: String,
88}
89
90/// Response from `POST /api/v1/shell/input`
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ShellInputResponse {
93    /// false if the shell is not running, has no captured stdin, or the write failed.
94    pub written: bool,
95    /// Why the write did not happen (None when `written` is true). Lets callers
96    /// distinguish "shell exited" from "shell is running but was created without
97    /// capture_stdin=true", which are otherwise indistinguishable from `written`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub reason: Option<ShellInputFailure>,
100}
101
102/// Reason a `ShellInput` write did not happen.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum ShellInputFailure {
106    /// The shell id is unknown or the process has already exited.
107    NotRunning,
108    /// The shell is running but was created without `capture_stdin=true`, so its
109    /// stdin is `/dev/null` — recreate it with capture_stdin to send input.
110    StdinNotCaptured,
111    /// The stdin relay is gone / the write channel is closed (process closed stdin).
112    WriteFailed,
113}
114
115/// `POST /api/v1/shell/status` — query whether a shell is still running.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ShellStatusRequest {
118    pub shell_id: String,
119}
120
121/// Response from `POST /api/v1/shell/status`
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ShellStatusResponse {
124    pub running: bool,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub exit_code: Option<i32>,
127    pub line_count: u64,
128}
129
130// ── PTY Shell ─────────────────────────────────────────────────────────────────
131//
132// A REAL PTY-backed shell (not the piped subprocess `Shell` above) — for
133// driving genuinely interactive programs that check for a real terminal
134// (Sharprompt-style wizards, `sudo`, `ssh`, REPLs). Reuses the same backend
135// `blockcontroller::shell` already uses for the `term` widget and
136// `AgentShellSubblock.tsx`; this is a new agent-facing entry point onto
137// existing PTY plumbing, not a new implementation. See
138// docs/specs/SPEC_AGENT_INTERACTIVE_PTY_SHELL_API_2026_09_10.md.
139//
140// Deliberately NOT UI-driven: every one of these is a plain backend RPC
141// (agent -> agentmux-mcp -> agentmux-srv), with no dependency on any window
142// being open, focused, or even rendering the shell's `term` sub-block — the
143// same posture the existing `Shell`/`ShellInput` family already has.
144//
145// `create` attaches to (creating if needed) the SAME shell a human's
146// composer-drawer session uses, not an independent, invisible one —
147// whichever side gets there first, the other joins. While the agent is
148// actively writing (`input`/`resize`), the human's own keyboard input to
149// that shell is locked out, for a short, self-expiring window
150// (`AGENT_LOCK_WINDOW_MS`) rather than an explicit lock/unlock the agent
151// could fail to release — "bound to the shell only when necessary," per
152// the repo owner's own framing of the requirement.
153
154/// `POST /api/v1/ptyshell/create`
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct PtyShellCreateRequest {
157    pub agent_block_id: String,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub cwd: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub rows: Option<u16>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub cols: Option<u16>,
164    /// Where the shell runs: `local` (the default) or `wsl://<distro>`. An
165    /// SSH destination is refused until SSH terminals ship
166    /// (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md §8).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub connection: Option<String>,
169    /// The calling agent's signed identity (`UiAutomationAuth`, its own jekt
170    /// key). Required for an SSH `connection`: srv derives the agent's pane
171    /// from it and refuses a request naming another pane, so no agent can
172    /// borrow another's SSH consent.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub auth: Option<UiAutomationAuth>,
175}
176
177/// Response from `POST /api/v1/ptyshell/create`. `shell_id` is the new
178/// sub-block's id — the same value every other `PtyShell*` call takes.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct PtyShellCreateResponse {
181    pub shell_id: String,
182}
183
184/// `POST /api/v1/ptyshell/input` — write raw text to the PTY, as if typed.
185///
186/// `agent_block_id` is filled in by `agentmux-mcp` from its own trusted
187/// env (`AGENTMUX_AGENT_BUS_ID`/`AGENTMUX_BLOCKID`), never a model-facing
188/// tool parameter — same non-forgeable guarantee `agent_slug()` documents.
189/// It's what lets the backend verify `shell_id` is actually a sub-block of
190/// THIS caller's own pane before acting on it (see
191/// `is_owned_by_agent` in `agentmux-srv`).
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct PtyShellInputRequest {
194    pub shell_id: String,
195    pub agent_block_id: String,
196    pub text: String,
197}
198
199/// Response from `POST /api/v1/ptyshell/input`
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct PtyShellInputResponse {
202    pub written: bool,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub error: Option<String>,
205}
206
207/// `POST /api/v1/ptyshell/resize` — resize the PTY. Some TUIs render
208/// differently or wrap badly at the fallback geometry (25x200).
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PtyShellResizeRequest {
211    pub shell_id: String,
212    pub agent_block_id: String,
213    pub rows: u16,
214    pub cols: u16,
215}
216
217/// Response from `POST /api/v1/ptyshell/resize`
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct PtyShellResizeResponse {
220    pub resized: bool,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub error: Option<String>,
223}
224
225/// `POST /api/v1/ptyshell/read` — read back the shell's raw output tail.
226///
227/// This is a raw byte/line log, not a rendered-screen snapshot — a
228/// full-screen TUI that redraws in place (cursor movement, `\r`-only
229/// updates) will not read back the way it visually renders. Sufficient for
230/// line-oriented wizards (the common case); see the spec's §5 for the
231/// rendered-snapshot follow-up this deliberately does not attempt yet.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct PtyShellReadRequest {
234    pub shell_id: String,
235    pub agent_block_id: String,
236    /// Number of trailing lines to return. Defaults to 200.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub tail_lines: Option<u32>,
239}
240
241/// Response from `POST /api/v1/ptyshell/read`
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct PtyShellReadResponse {
244    pub content: String,
245    /// True if the shell's output has more lines than `tail_lines` returned.
246    pub truncated: bool,
247}
248
249/// `POST /api/v1/ptyshell/status` — query whether the shell is still running.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PtyShellStatusRequest {
252    pub shell_id: String,
253    pub agent_block_id: String,
254}
255
256/// Response from `POST /api/v1/ptyshell/status`
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct PtyShellStatusResponse {
259    /// False both for "still initializing" and "exited" — callers that need
260    /// to distinguish those should check `exit_code` (`None` for the former).
261    pub running: bool,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub exit_code: Option<i32>,
264}
265
266/// `POST /api/v1/ptyshell/stop` — release the agent's lock on the shell
267/// *now*, without waiting for `AGENT_LOCK_WINDOW_MS` to lapse.
268///
269/// Does **not** kill the process or delete the block. The shell this API
270/// drives is, by default, the same one a human's composer-drawer session
271/// uses (see `META_KEY_SHELL_SUBBLOCK_ID`) — it's a shared, pane-scoped
272/// resource with the pane's own lifetime ("only killed when the pane
273/// closes," `AgentShellSubblock.tsx`'s own doc comment), not a private,
274/// disposable one this tool owns outright. An earlier version of this API
275/// (PR #3177) did delete on stop, which was correct for that version's
276/// model (an agent's own throwaway shell) but would destroy a human's live
277/// terminal session under this one.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct PtyShellStopRequest {
280    pub shell_id: String,
281    pub agent_block_id: String,
282}
283
284/// Response from `POST /api/v1/ptyshell/stop`
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PtyShellStopResponse {
287    /// False if the id was unrecognized, wasn't owned by the caller, or
288    /// simply had no active lock to release.
289    pub released: bool,
290}
291
292// ── Inject (SendMessage + Loop) ───────────────────────────────────────────────
293
294/// `POST /agentmux/reactive/inject` — deliver a message to an agent.
295///
296/// Used by the `SendMessage` and `Loop` MCP tools.
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct InjectRequest {
299    pub target_agent: String,
300    pub message: String,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub source_agent: Option<String>,
303    /// Message id this jekt was signed under — required for `jekt_sig` to be
304    /// verifiable (the signed material binds msgid, sender, target, and
305    /// timestamp together). `None` when unsigned (e.g. `AGENTMUX_JEKT_KEY`
306    /// wasn't available — legacy/unverified, same as omitting `jekt_sig`).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub request_id: Option<String>,
309    /// Unix seconds this jekt was signed at — part of the signed material,
310    /// not just a display timestamp. See `agentmux_common::jekt_sign`.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub ts_secs: Option<i64>,
313    /// Base64 HMAC-SHA256 over (msgid, source_agent, target_agent, ts_secs,
314    /// message), signed with the sender's own `AGENTMUX_JEKT_KEY`. Absent
315    /// when the sending agent has no key yet (first-ever send before one is
316    /// provisioned) — srv treats an absent/invalid signature as unverified,
317    /// not as an error, and downgrades trust accordingly rather than
318    /// rejecting the message outright.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub jekt_sig: Option<String>,
321    /// Base64 Ed25519 signature over the same signed material as `jekt_sig`,
322    /// produced with the sender's own `AGENTMUX_LAN_KEY`
323    /// (SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.3). Sent unconditionally
324    /// alongside `jekt_sig` regardless of the message's actual destination
325    /// — this process can't know in advance whether delivery will end up
326    /// LAN, host, or WAN (that's a server-side routing decision); srv only
327    /// ever consults this field when it has independently determined
328    /// `delivery_tier == "lan"`, so it's simply ignored otherwise. Absent
329    /// under the same "no key yet" conditions as `jekt_sig`.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub lan_sig: Option<String>,
332    /// The sending instance's channel id (`AGENTMUX_CHANNEL`, injected into
333    /// the MCP env at spawn alongside the keys). Bound into `channel_sig`'s
334    /// signed material (SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md §D5) so
335    /// a signature minted in one channel can't be replayed as the same agent
336    /// speaking from another. Absent whenever `channel_sig` is.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub source_channel: Option<String>,
339    /// Base64 Ed25519 signature for the same-machine, different-instance
340    /// (cross-channel) tier, produced with the sender's own `AGENTMUX_LAN_KEY`
341    /// over a domain-separated payload that also binds `source_channel`
342    /// (`agentmux_common::jekt_sign::sign_channel_jekt`). Sent unconditionally
343    /// for the same reason `lan_sig` is: srv only consults it once it has
344    /// itself labelled the delivery `channel`. Absent under the same "no key
345    /// yet" conditions as `lan_sig`.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub channel_sig: Option<String>,
348    /// Identity M4d-6 (`SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
349    /// §6.5.10): the sending agent's UID, as its MCP fetched it with its
350    /// UID-keyed key (M4d-3). Bound into `channel_sig_v2`; a forwarding srv
351    /// never sets or changes it. Absent when the sender has no UID key.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub source_uid: Option<String>,
354    /// Base64 Ed25519 signature, made only with the sender's UID-keyed LAN
355    /// key, over the cross-channel material plus `source_uid`
356    /// (`agentmux_common::jekt_sign::sign_channel_jekt_v2`). Verified by UID
357    /// against the `uid_public_key` an instance published for it (M4d-5), so
358    /// it proves *which* agent sent the jekt, not just which name. The v1
359    /// `channel_sig` is still sent beside it.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub channel_sig_v2: Option<String>,
362    /// Identity M4d-6: base64 Ed25519 signature, made only with the sender's
363    /// UID-keyed LAN key, over the LAN material plus `source_uid`
364    /// (`agentmux_common::jekt_sign::sign_lan_jekt_v2`). A receiver checks it
365    /// only after the v1 `lan_sig` verified, and records the UID as claimed by
366    /// that LAN path, never as verified attribution.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub lan_sig_v2: Option<String>,
369    /// Base64 Ed25519 signature for the general agent-to-agent WAN tier,
370    /// produced with the sender's own `AGENTMUX_WAN_KEY` — a *different* key
371    /// from `AGENTMUX_LAN_KEY` — over a domain-separated payload
372    /// (`agentmux_common::jekt_sign::sign_wan_jekt`). Sent
373    /// unconditionally for the same reason `lan_sig` and `channel_sig` are:
374    /// the sending process cannot know which tier srv will route the message
375    /// over.
376    ///
377    /// **Nothing verifies this yet.** Verification requires resolving
378    /// `(sender_account, source_agent)` to exactly one published key, which
379    /// waits on relay-side work tracked in the private cloud repo. The field
380    /// is carried now so that keys are
381    /// minted and propagating by the time a verifier exists — an agent only
382    /// gets a key when it is spawned.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub wan_sig: Option<String>,
385    /// The sending machine's host label (`AGENTMUX_HOST_LABEL`, injected at
386    /// spawn). With `source_channel` this identifies which AgentMux *instance*
387    /// under the sending account owns the keypair `wan_sig` was made with —
388    /// one account can run the same agent name on several machines, and on
389    /// several build channels of one machine, each with its own database and
390    /// therefore its own key. Bound into the signed material, so a wrong value selects a key
391    /// the signature cannot verify under rather than granting anything.
392    /// Absent whenever `wan_sig` is.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub wan_source_host: Option<String>,
395}
396
397// ── Pane ──────────────────────────────────────────────────────────────────────
398
399/// `POST /api/v1/pane/open` — open a new pane.
400///
401/// Structurally equivalent to `rpc_types::CommandPaneOpenData` (same wire
402/// fields) so the two are interchangeable on the wire; srv deserializes into
403/// `CommandPaneOpenData`, mcp serializes from this type.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct PaneOpenRequest {
406    pub view: String,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub file: Option<String>,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub url: Option<String>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub cwd: Option<String>,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub title: Option<String>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub tab_id: Option<String>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub split_direction: Option<String>,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub split_reference_block_id: Option<String>,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub focus: Option<bool>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub tree_expanded: Option<bool>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub floating: Option<bool>,
427    /// `Some(true)`, `view: "editor"` only: explicit opt-in to reuse an
428    /// already-open Editor pane in the caller's own tab (identified via
429    /// `split_reference_block_id`) instead of always creating a new one.
430    /// Set only by the `OpenEditor` MCP tool — NOT inferred from other
431    /// fields, since other legitimate `pane.open` callers also set
432    /// `split_reference_block_id` for split placement without wanting
433    /// reuse (`EditorViewModel.openToTheSide`/`openInTerminal`).
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub reuse_editor_pane: Option<bool>,
436    /// `view: "files"` only: names (or full paths) inside `file` to select
437    /// once the Files pane has listed it. Set by the `OpenFiles` MCP tool.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub select: Option<Vec<String>>,
440    /// The connection the pane works on (an SSH host, `wsl://<distro>`);
441    /// absent for this computer. An SSH host needs `auth`.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub connection: Option<String>,
444    /// The calling agent's signed identity, for an SSH `connection`.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub auth: Option<UiAutomationAuth>,
447}
448
449/// Response from `POST /api/v1/pane/open`
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct PaneOpenResponse {
452    pub block_id: String,
453}
454
455/// `POST /api/v1/pane/title` — set a pane's display title.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct PaneTitleRequest {
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub block_id: Option<String>,
460    pub title: String,
461}
462
463// ── Tab ───────────────────────────────────────────────────────────────────────
464
465/// `POST /api/v1/tab/activate` — switch the active tab.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct TabActivateRequest {
468    pub tab_id: String,
469}
470
471/// `POST /api/v1/tab/new` — create a new tab in a workspace.
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct TabNewRequest {
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub block_id: Option<String>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub workspace_id: Option<String>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub name: Option<String>,
480}
481
482/// `POST /api/v1/tab/name` — rename a tab.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct TabNameRequest {
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub block_id: Option<String>,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub tab_id: Option<String>,
489    pub name: String,
490}
491
492// ── Window ────────────────────────────────────────────────────────────────────
493
494/// `POST /api/v1/window/focus` — bring a window to the foreground.
495#[derive(Debug, Clone, Serialize, Deserialize)]
496pub struct WindowFocusRequest {
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub block_id: Option<String>,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub window_id: Option<String>,
501}
502
503/// `POST /api/v1/window/name` — set a window's display name.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct WindowNameRequest {
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub block_id: Option<String>,
508    pub name: String,
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub window_id: Option<String>,
511}
512
513// ── Workspace ─────────────────────────────────────────────────────────────────
514
515/// `POST /api/v1/workspace/name` — rename a workspace.
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct WorkspaceNameRequest {
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub block_id: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub workspace_id: Option<String>,
522    pub name: String,
523}
524
525// ── UI automation ───────────────────────────────────────────────────────────
526//
527// `block_id` is NOT a field on any of these requests, on purpose (2026-08-19,
528// reagent + Codex review, PR #2662 — a client-supplied `block_id` here was a
529// real cross-agent content-disclosure vulnerability: /api/v1/ui/* shares the
530// same instance-wide X-AuthKey every App-API route trusts, and any agent can
531// read that key from its own environment, so a bare `block_id` field could
532// never be trusted from the request body no matter which layer stamped it).
533//
534// Instead, every request here carries `UiAutomationAuth`: the calling
535// agent's own slug, a timestamp, and an HMAC-SHA256 signature over them
536// using that agent's own `AGENTMUX_JEKT_KEY` (the SAME per-agent signing key
537// already used for jekt sender authentication — reused rather than inventing
538// a parallel credential system; see `agentmux_common::jekt_sign`). srv
539// verifies the signature against the claimed agent_id's key on file
540// (`Store::agent_jekt_key_load`) and, ONLY once that succeeds, looks up that
541// agent's actual current block_id server-side (`ReactiveHandler::get_agent`)
542// — the block_id a UI-automation call actually operates on is never taken
543// from the client at all, so there is nothing left to spoof. See
544// `crates/srv/src/server/ui_handlers.rs::verified_block_id` and
545// docs/specs/SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md.
546
547/// Identity proof shared by every `/api/v1/ui/*` request. `sig` is
548/// `jekt_sign::sign_jekt(key, "ui-automation-identity", agent_id,
549/// "__srv__", ts_secs, "")` — the fixed msgid/target_agent/message values
550/// exist purely for domain separation from real jekt message signatures
551/// (no genuine jekt uses this literal msgid, and "__srv__" is not a
552/// spawnable agent name), not because those fields carry meaning here.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct UiAutomationAuth {
555    pub agent_id: String,
556    pub ts_secs: i64,
557    pub sig: String,
558}
559
560/// `POST /api/v1/ui/screenshot`
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct UiScreenshotRequest {
563    #[serde(flatten)]
564    pub auth: UiAutomationAuth,
565    /// Target a browser pane this agent opened with `OpenBrowser`
566    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
567    /// against its owner map; absent = the caller's own pane, as before.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub pane: Option<String>,
570}
571
572/// Response for `POST /api/v1/ui/screenshot`. `path` is a file already
573/// written to disk (openable via the `OpenMedia` tool); `png_base64` is the
574/// same image inline, for a caller that can render an MCP `ImageContent`
575/// block directly without a second round trip.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct UiScreenshotResponse {
578    pub path: String,
579    pub png_base64: String,
580}
581
582/// `POST /api/v1/ui/click`
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct UiClickRequest {
585    #[serde(flatten)]
586    pub auth: UiAutomationAuth,
587    /// Target a browser pane this agent opened with `OpenBrowser`
588    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
589    /// against its owner map; absent = the caller's own pane, as before.
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub pane: Option<String>,
592    pub selector: String,
593}
594
595/// `POST /api/v1/ui/query`
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct UiQueryRequest {
598    #[serde(flatten)]
599    pub auth: UiAutomationAuth,
600    /// Target a browser pane this agent opened with `OpenBrowser`
601    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
602    /// against its owner map; absent = the caller's own pane, as before.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub pane: Option<String>,
605    pub selector: String,
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub limit: Option<u32>,
608}
609
610// ── Shortcuts (ListShortcuts/RunCommand/PressKeys) ────────────────────────
611// docs/specs/PLAN_SHORTCUTS_VERIFIED_AND_APP_API_2026_10_10.md §4. They act
612// in the window that holds the caller's own pane (owner decision §8.1), so
613// there is no `pane` field; `target` is a pane in that window's active tab.
614
615/// `POST /api/v1/ui/shortcuts/list`
616#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct UiShortcutsListRequest {
618    #[serde(flatten)]
619    pub auth: UiAutomationAuth,
620}
621
622/// `POST /api/v1/ui/shortcuts/run`
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct UiRunCommandRequest {
625    #[serde(flatten)]
626    pub auth: UiAutomationAuth,
627    /// A command id from `ListShortcuts` (`split:right`, `files:refresh`).
628    pub command: String,
629    /// A pane in the active tab to focus first; absent = the focused pane.
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub target: Option<String>,
632}
633
634/// `POST /api/v1/ui/shortcuts/press`
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct UiPressKeysRequest {
637    #[serde(flatten)]
638    pub auth: UiAutomationAuth,
639    /// A key from `ListShortcuts`' `raw` list, in the table's syntax
640    /// (`ctrl+shift+d`; a chord is two keys separated by a space).
641    pub keys: String,
642    /// A pane in the active tab to focus first; absent = the focused pane.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub target: Option<String>,
645}
646
647// ── Browser-pane deep control (Navigate/Back/Forward/Reload/Eval/
648//    DispatchKey/FocusElement/FocusInfo) ─────────────────────────────────
649//
650// Same `UiAutomationAuth`-derived own-pane scoping as the UI-automation
651// section above (block_id is never a client field, always server-derived
652// via `verified_block_id`). Additionally, `Navigate`/`Back`/`Forward`/
653// `Reload`/`Eval` only succeed when the caller's own pane resolves to a
654// DEDICATED browser-pane CDP target (not a DOM node inside a page shared
655// with other panes / the app's own chrome) — enforced host-side by
656// `crates/cef/src/browser_api/routes.rs::reject_if_shared_target`.
657// See docs/specs/SPEC_AGENT_BROWSER_PANE_DEEP_CONTROL_2026_09_20.md.
658
659/// `POST /api/v1/ui/browser/navigate`
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct UiBrowserNavigateRequest {
662    #[serde(flatten)]
663    pub auth: UiAutomationAuth,
664    /// Target a browser pane this agent opened with `OpenBrowser`
665    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
666    /// against its owner map; absent = the caller's own pane, as before.
667    #[serde(default, skip_serializing_if = "Option::is_none")]
668    pub pane: Option<String>,
669    pub url: String,
670}
671
672/// `POST /api/v1/ui/browser/back`, `/forward`, `/reload` — all three only
673/// need the target block id; `ignore_cache` is meaningful for `/reload`
674/// only (ignored by back/forward, mirrors `browser_api::types::HistoryReq`).
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct UiBrowserHistoryRequest {
677    #[serde(flatten)]
678    pub auth: UiAutomationAuth,
679    /// Target a browser pane this agent opened with `OpenBrowser`
680    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
681    /// against its owner map; absent = the caller's own pane, as before.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub pane: Option<String>,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub ignore_cache: Option<bool>,
686}
687
688/// `POST /api/v1/ui/browser/eval`
689#[derive(Debug, Clone, Serialize, Deserialize)]
690pub struct UiBrowserEvalRequest {
691    #[serde(flatten)]
692    pub auth: UiAutomationAuth,
693    /// Target a browser pane this agent opened with `OpenBrowser`
694    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
695    /// against its owner map; absent = the caller's own pane, as before.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub pane: Option<String>,
698    pub script: String,
699    /// If true and the script returns a Promise, wait for it to resolve
700    /// before returning.
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub await_promise: Option<bool>,
703}
704
705/// `POST /api/v1/ui/browser/dispatch_key`
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub struct UiBrowserDispatchKeyRequest {
708    #[serde(flatten)]
709    pub auth: UiAutomationAuth,
710    /// Target a browser pane this agent opened with `OpenBrowser`
711    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
712    /// against its owner map; absent = the caller's own pane, as before.
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub pane: Option<String>,
715    /// Optional CSS selector: focus this element before dispatching.
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub selector: Option<String>,
718    /// Exactly one of `text`/`key` must be set. Sent via `Input.insertText`.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub text: Option<String>,
721    /// Named key (`Enter`, `Tab`, `Escape`, `Backspace`, arrow keys,
722    /// `Space`) sent as a `keyDown`+`keyUp` pair.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub key: Option<String>,
725}
726
727/// `POST /api/v1/ui/browser/focus_element`
728#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct UiBrowserFocusElementRequest {
730    #[serde(flatten)]
731    pub auth: UiAutomationAuth,
732    /// Target a browser pane this agent opened with `OpenBrowser`
733    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
734    /// against its owner map; absent = the caller's own pane, as before.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub pane: Option<String>,
737    pub selector: String,
738}
739
740/// `POST /api/v1/ui/browser/focus_info`
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct UiBrowserFocusInfoRequest {
743    #[serde(flatten)]
744    pub auth: UiAutomationAuth,
745    /// Target a browser pane this agent opened with `OpenBrowser`
746    /// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3). srv checks it
747    /// against its owner map; absent = the caller's own pane, as before.
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub pane: Option<String>,
750}
751
752/// `POST /api/v1/ui/browser/snapshot` — backs `BrowserSnapshot`
753/// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §4.1).
754#[derive(Debug, Clone, Serialize, Deserialize)]
755pub struct UiBrowserSnapshotRequest {
756    #[serde(flatten)]
757    pub auth: UiAutomationAuth,
758    /// Target a browser pane this agent opened with `OpenBrowser`; absent =
759    /// the caller's own pane.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub pane: Option<String>,
762    /// A reference from the previous snapshot: narrow to that element.
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub scope: Option<String>,
765}
766
767/// `POST /api/v1/ui/browser/act` — backs `BrowserClick`, `BrowserFill`,
768/// `BrowserSelect` and `BrowserCheck` (spec §4.2).
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct UiBrowserActRequest {
771    #[serde(flatten)]
772    pub auth: UiAutomationAuth,
773    /// Target a browser pane this agent opened with `OpenBrowser`; absent =
774    /// the caller's own pane.
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub pane: Option<String>,
777    /// A reference from the latest snapshot, e.g. `e12`.
778    #[serde(rename = "ref")]
779    pub ref_: String,
780    /// `click`, `fill`, `select` or `check`.
781    pub action: String,
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub text: Option<String>,
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub option: Option<String>,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub checked: Option<bool>,
788}
789
790/// `POST /api/v1/ui/browser/set_files` — backs `BrowserSetFiles`
791/// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §5.3). srv allows only
792/// files inside the calling agent's own workspace.
793#[derive(Debug, Clone, Serialize, Deserialize)]
794pub struct UiBrowserSetFilesRequest {
795    #[serde(flatten)]
796    pub auth: UiAutomationAuth,
797    /// Target a browser pane this agent opened with `OpenBrowser`; absent =
798    /// the caller's own pane.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub pane: Option<String>,
801    /// The file input's reference from the latest snapshot.
802    #[serde(rename = "ref")]
803    pub ref_: String,
804    /// Absolute, or relative to the agent's workspace.
805    pub paths: Vec<String>,
806}
807
808/// `POST /api/v1/ui/browser/wait_for` — backs `BrowserWaitFor`.
809/// Exactly one of `text`, `url_contains`, `gone`.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct UiBrowserWaitForRequest {
812    #[serde(flatten)]
813    pub auth: UiAutomationAuth,
814    /// Target a browser pane this agent opened with `OpenBrowser`; absent =
815    /// the caller's own pane.
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub pane: Option<String>,
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub text: Option<String>,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub url_contains: Option<String>,
822    /// A reference whose element should leave the page.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub gone: Option<String>,
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub timeout_ms: Option<u64>,
827}
828
829/// `POST /api/v1/ui/browser/handoff` — backs `BrowserHandoff`
830/// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §5.2): show the user a
831/// banner in the pane and wait for Done or Cancel.
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct UiBrowserHandoffRequest {
834    #[serde(flatten)]
835    pub auth: UiAutomationAuth,
836    /// Target a browser pane this agent opened with `OpenBrowser`; absent =
837    /// the caller's own pane.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub pane: Option<String>,
840    /// What the user is asked to do, e.g. "Sign in to your Microsoft account".
841    pub reason: String,
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub timeout_minutes: Option<u64>,
844}
845
846/// `POST /api/v1/ui/browser/open` — backs `OpenBrowser`. Opens a browser
847/// pane next to the caller's own pane and records the caller as its owner
848/// (SPEC_AGENT_DRIVEN_BROWSER_PANES_2026_10_07.md §3).
849#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct UiBrowserOpenRequest {
851    #[serde(flatten)]
852    pub auth: UiAutomationAuth,
853    pub url: String,
854    /// `right` (default), `left`, `up` or `down`, relative to the caller's pane.
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub split: Option<String>,
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub title: Option<String>,
859    /// Keep the pane on these sites (`allowed_origins::parse` forms); a
860    /// navigation elsewhere asks the person
861    /// (SPEC_BROWSER_PANE_ALLOWED_ORIGINS_2026_10_09.md).
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub allowed_origins: Option<Vec<String>>,
864    /// Browse as `"incognito"` (a fresh jar of its own) or a named browser
865    /// profile the user lets agents use; absent: Personal
866    /// (SPEC_BROWSER_PANE_PROFILES_MENU_2026_10_09.md §5).
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub profile: Option<String>,
869}
870
871/// Response for `POST /api/v1/ui/browser/open`: the new pane's block id,
872/// which the `Browser*` and `UI*` tools take as `pane`.
873#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct UiBrowserOpenResponse {
875    pub pane: String,
876}
877
878/// `POST /api/v1/agent/pane/close` — backs the `ClosePane` MCP tool.
879/// See docs/specs/SPEC_AGENT_PANE_LIFECYCLE_CONTROL_2026_09_10.md.
880///
881/// `auth` is reused unchanged from `UiAutomationAuth` (the same signed
882/// per-agent identity `verified_block_id` already checks for UI automation)
883/// — it is what resolves the caller's OWN pane when `block_id` is omitted,
884/// and what supplies a verified `source_agent` for the audit log when
885/// `block_id` targets another agent's pane (§5.2 of that spec).
886/// `QuitSelf` — an agent ends its own session on the user's direct
887/// instruction (docs/specs/SPEC_AGENT_SELF_QUIT_2026_09_24.md §6). There is
888/// no target field: the caller's own block, from `auth`, is the only one it
889/// can name.
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub struct QuitSelfRequest {
892    #[serde(flatten)]
893    pub auth: UiAutomationAuth,
894    /// One line: why. Shown to the user and audited.
895    pub reason: String,
896    /// The user's own words telling the agent to quit, verbatim. Checked
897    /// against the message that started the current turn (§6.3).
898    pub user_instruction: String,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize)]
902pub struct ClosePaneRequest {
903    #[serde(flatten)]
904    pub auth: UiAutomationAuth,
905    /// Target pane to close. Omitted = the caller's own pane (self-only,
906    /// no ownership check needed — it's already the caller's). Present =
907    /// fleet tier: close ANY pane by block_id, no ownership check on the
908    /// TARGET either, by design (§5.1) — this is the capability that
909    /// motivated the whole spec.
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub block_id: Option<String>,
912    /// Optional human-readable reason, threaded into the audit log entry
913    /// when closing another agent's pane.
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub reason: Option<String>,
916}
917
918/// `POST /api/v1/agent/dev_server/register` — backs the `RegisterDevServer`
919/// MCP tool. See docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
920///
921/// `auth` is reused unchanged from `UiAutomationAuth`, same as
922/// `ClosePaneRequest` above — the calling agent proves it IS `auth.agent_id`
923/// via its own `AGENTMUX_JEKT_KEY` signature; there is no client-supplied
924/// agent id to spoof. The backend address the routing table actually
925/// stores is resolved server-side from THAT agent's own container (never a
926/// client-supplied address) — this request only ever supplies the PORT the
927/// caller's own dev server is listening on inside its own container.
928#[derive(Debug, Clone, Serialize, Deserialize)]
929pub struct RegisterDevServerRequest {
930    #[serde(flatten)]
931    pub auth: UiAutomationAuth,
932    /// Project name — becomes part of the routing hostname,
933    /// `<project>-<agent_id>.localhost`. Lowercased server-side before use;
934    /// should be a short, hostname-safe slug.
935    pub project: String,
936    /// Port the dev server is listening on INSIDE the caller's own
937    /// container (container-internal — never a host-published port).
938    pub port: u16,
939}
940
941/// Response for `POST /api/v1/agent/dev_server/register`.
942#[derive(Debug, Clone, Serialize, Deserialize)]
943pub struct RegisterDevServerResponse {
944    /// The full routable URL, e.g. `"http://pulse-korp.localhost:8090"` —
945    /// what to actually browse to.
946    pub url: String,
947}
948
949#[cfg(test)]
950mod app_api_manifest_contract_tests {
951    //! Rust half of the DRY contract check for the `shell.*` routes,
952    //! described in docs/specs/SPEC_MUXSH_FULL_COLLECTION_2026_09_16.md
953    //! §2.8 — same mechanism and rationale as
954    //! `crates/srv/src/backend/rpc_types/block.rs`'s
955    //! `app_api_manifest_contract_tests` module for `pane.open`. A Node-side
956    //! test (`muxsh.contract.test.mjs`) makes the matching assertion against
957    //! the same manifest.
958    use super::*;
959    use std::collections::HashSet;
960    use std::path::Path;
961
962    fn repo_root() -> std::path::PathBuf {
963        Path::new(env!("CARGO_MANIFEST_DIR"))
964            .parent()
965            .and_then(Path::parent)
966            .expect("crates/<name> is two levels below the repo root")
967            .to_path_buf()
968    }
969
970    fn load_manifest() -> serde_json::Value {
971        let path = repo_root().join("docs/specs/app-api-manifest.json");
972        let raw = std::fs::read_to_string(&path)
973            .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
974        serde_json::from_str(&raw).expect("app-api-manifest.json must be valid JSON")
975    }
976
977    fn manifest_fields(manifest: &serde_json::Value, route: &str, key: &str) -> HashSet<String> {
978        manifest["routes"][route][key]
979            .as_array()
980            .unwrap_or_else(|| panic!("routes.{route}.{key} must be an array"))
981            .iter()
982            .map(|v| v.as_str().expect("field name must be a string").to_string())
983            .collect()
984    }
985
986    fn assert_exact_match(manifest_fields: &HashSet<String>, struct_fields: &HashSet<String>, route: &str) {
987        let manifest_only: Vec<_> = manifest_fields.difference(struct_fields).collect();
988        let struct_only: Vec<_> = struct_fields.difference(manifest_fields).collect();
989        assert!(
990            manifest_only.is_empty() && struct_only.is_empty(),
991            "app-api-manifest.json's {route} has drifted from the real struct — \
992             in manifest but not the struct: {manifest_only:?}; \
993             in the struct but not the manifest: {struct_only:?}"
994        );
995    }
996
997    #[test]
998    fn shell_create_request_fields_match_the_real_struct() {
999        let manifest = load_manifest();
1000        let instance = ShellCreateRequest {
1001            agent_block_id: "b".to_string(),
1002            cmd: "echo hi".to_string(),
1003            cwd: Some("/tmp".to_string()),
1004            title: Some("t".to_string()),
1005            env: Some(Default::default()),
1006            capture_stdin: Some(true),
1007            connection: Some("wsl://Ubuntu".to_string()),
1008            auth: Some(UiAutomationAuth { agent_id: "a".into(), ts_secs: 1, sig: "s".into() }),
1009        };
1010        let value = serde_json::to_value(&instance).expect("must serialize");
1011        let struct_fields: HashSet<String> =
1012            value.as_object().expect("must be an object").keys().cloned().collect();
1013        assert_exact_match(&manifest_fields(&manifest, "shell.create", "requestFields"), &struct_fields, "shell.create.requestFields");
1014    }
1015
1016    #[test]
1017    fn shell_create_response_fields_match_the_real_struct() {
1018        let manifest = load_manifest();
1019        let instance = ShellCreateResponse { shell_id: "s".to_string() };
1020        let value = serde_json::to_value(&instance).expect("must serialize");
1021        let struct_fields: HashSet<String> =
1022            value.as_object().expect("must be an object").keys().cloned().collect();
1023        assert_exact_match(&manifest_fields(&manifest, "shell.create", "responseFields"), &struct_fields, "shell.create.responseFields");
1024    }
1025
1026    #[test]
1027    fn shell_status_fields_match_the_real_structs() {
1028        let manifest = load_manifest();
1029        let req = ShellStatusRequest { shell_id: "s".to_string() };
1030        let req_fields: HashSet<String> = serde_json::to_value(&req)
1031            .expect("must serialize")
1032            .as_object()
1033            .expect("must be an object")
1034            .keys()
1035            .cloned()
1036            .collect();
1037        assert_exact_match(&manifest_fields(&manifest, "shell.status", "requestFields"), &req_fields, "shell.status.requestFields");
1038
1039        let resp = ShellStatusResponse { running: true, exit_code: Some(0), line_count: 1 };
1040        let resp_fields: HashSet<String> = serde_json::to_value(&resp)
1041            .expect("must serialize")
1042            .as_object()
1043            .expect("must be an object")
1044            .keys()
1045            .cloned()
1046            .collect();
1047        assert_exact_match(&manifest_fields(&manifest, "shell.status", "responseFields"), &resp_fields, "shell.status.responseFields");
1048    }
1049
1050    #[test]
1051    fn shell_stop_fields_match_the_real_structs() {
1052        let manifest = load_manifest();
1053        let req = ShellStopRequest { shell_id: "s".to_string() };
1054        let req_fields: HashSet<String> = serde_json::to_value(&req)
1055            .expect("must serialize")
1056            .as_object()
1057            .expect("must be an object")
1058            .keys()
1059            .cloned()
1060            .collect();
1061        assert_exact_match(&manifest_fields(&manifest, "shell.stop", "requestFields"), &req_fields, "shell.stop.requestFields");
1062
1063        let resp = ShellStopResponse { stopped: true };
1064        let resp_fields: HashSet<String> = serde_json::to_value(&resp)
1065            .expect("must serialize")
1066            .as_object()
1067            .expect("must be an object")
1068            .keys()
1069            .cloned()
1070            .collect();
1071        assert_exact_match(&manifest_fields(&manifest, "shell.stop", "responseFields"), &resp_fields, "shell.stop.responseFields");
1072    }
1073}