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}