返回 CodeWhale
protocol.rs
根目录 / crates / tui / src / extension_host / protocol.rs
1 //! Extension-host protocol v1 — the phase-1 subset, and its source of truth.
2 //!
3 //! Frame: 4-byte magic `CWX1`, u32 little-endian payload length, UTF-8 JSON.
4 //! Envelope: JSON-RPC 2.0. A length prefix (not NDJSON) makes a plugin that
5 //! writes raw bytes to the channel detectable: bad magic or length ends the
6 //! host instead of desynchronising it.
7 //!
8 //! Host→core types use `deny_unknown_fields` — host output is untrusted
9 //! input. Core→host types are what this side writes.
10 //!
11 //! **This file is the protocol's single source.** [`METHODS`] is every method
12 //! either side may send; both parsers admit nothing else. The TypeScript
13 //! side's constants, method table, params shapes and wire types are generated
14 //! from the types here into `crates/tui/extension-host/src/protocol.generated.ts`
15 //! by `protocol/tests.rs`, which fails when the committed file drifts. What
16 //! stays hand-written in `protocol.ts`: the frame codec, the JSON-RPC envelope
17 //! checks, and the one rule [`parse_host_message`] applies beyond the types
18 //! (`host/hello`'s runtime name). Both sides also parse the shared corpus in
19 //! `tests/fixtures/extension_host/protocol`. Known limit: result shapes are
20 //! generated as TypeScript types only; the host does not validate the
21 //! results the core sends it, and the core validates what it reads.
22 //!
23 //! **Tiers.** Each [`MethodSpec`] carries the trust tiers (`HostTier`) that may
24 //! send or be sent it, and both parsers and the sender enforce that: a
25 //! plugin-tier host is never sent, and a plugin-tier host's frame is never
26 //! admitted for, a method reserved for the built-in tier. Every method allows
27 //! both tiers today ([`ALL_TIERS`]); a reserved method is a row written as
28 //! `MethodSpec { tiers: &[HostTier::Builtin], ..row(direction, name, request) }`.
29 //! `host/hello` carries the tier the host serves and the digests of the built-in
30 //! modules its bundle embeds, which the core checks against what it launched
31 //! (`supervisor::check_hello_identity`).
32 //!
33 //! There is deliberately no method that expresses approval. Registrations
34 //! are admitted or refused and tool calls flow core→host after the gate. The
35 //! builtin-only broker methods redeem single-use exact Rust-issued operation
36 //! tickets; they expose no launch command, credential or decision key. `command/run`
37 //! flows core→host only when the user invokes the command themselves; the
38 //! host answers with text or a prompt, and the core decides whether and how
39 //! to show or submit it. The authority lint in `protocol/tests.rs` keeps
40 //! [`METHODS`] that way.
41
42 use std::time::Duration;
43
44 use serde::de::DeserializeOwned;
45 use serde::{Deserialize, Deserializer, Serialize};
46 use serde_json::{Map, Value, json};
47 use tokio::io::{AsyncRead, AsyncReadExt};
48
49 use super::tier::HostTier;
50
51 pub const PROTOCOL_VERSION: u32 = 1;
52 pub const MAGIC: [u8; 4] = *b"CWX1";
53 pub const HEADER_LEN: usize = 8;
54 /// Enough for base64 screenshots later; anything larger is refused, never truncated.
55 pub const MAX_FRAME: usize = 32 * 1024 * 1024;
56 /// Requests in flight per direction.
57 pub const MAX_INFLIGHT: usize = 256;
58
59 /// JSON-RPC error codes used on this channel.
60 pub mod error_code {
61 /// A method the receiver has no handler for (the core never sends one the
62 /// host's tier may not receive; see `MethodSpec::tiers`).
63 pub const METHOD_NOT_FOUND: i64 = -32601;
64 pub const INVALID_PARAMS: i64 = -32602;
65 /// Unknown, revoked, or not-yet-active handle.
66 pub const NOT_AVAILABLE: i64 = -32001;
67 /// Cancelled by `$/cancel`.
68 pub const CANCELLED: i64 = -32800;
69 /// `core/call`: the core's policy refuses the call (a tool the extension
70 /// may not reach, a limit, an invalid ticket), or the host's request is
71 /// refused for a reason that is not a person's answer.
72 pub const REFUSED: i64 = -32002;
73 /// `core/call`: the user declined the approval card for it.
74 pub const DENIED: i64 = -32003;
75
76 /// Every code on the channel, by the name the TypeScript side uses. The
77 /// core interprets only the three above; the host also answers with the
78 /// standard JSON-RPC codes and `ExecutionFailed` (a tool body threw),
79 /// which reach the caller as `HostCallError::Rpc`.
80 #[cfg(test)]
81 pub const ALL: &[(&str, i64)] = &[
82 ("ParseError", -32700),
83 ("InvalidRequest", -32600),
84 ("MethodNotFound", METHOD_NOT_FOUND),
85 ("InvalidParams", INVALID_PARAMS),
86 ("Internal", -32603),
87 ("ExecutionFailed", -32000),
88 ("NotAvailable", NOT_AVAILABLE),
89 ("Refused", REFUSED),
90 ("Denied", DENIED),
91 ("Cancelled", CANCELLED),
92 ];
93 }
94
95 /// Which side sends a method.
96 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
97 pub enum Direction {
98 CoreToHost,
99 HostToCore,
100 }
101
102 impl Direction {
103 /// The corpus and TypeScript spelling.
104 #[must_use]
105 pub fn as_str(self) -> &'static str {
106 match self {
107 Self::CoreToHost => "core_to_host",
108 Self::HostToCore => "host_to_core",
109 }
110 }
111 }
112
113 /// One method either side may send.
114 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
115 pub struct MethodSpec {
116 pub name: &'static str,
117 pub direction: Direction,
118 /// A request carries an id and gets a response; a notification does not.
119 pub request: bool,
120 /// The trust tiers whose host may send (host to core) or be sent (core to
121 /// host) this method. Enforced in both directions: [`admit_in`] on what is
122 /// received, [`allowed_on`] before the core sends.
123 pub tiers: &'static [HostTier],
124 }
125
126 /// Both trust tiers: what a method allows unless it is reserved.
127 pub const ALL_TIERS: &[HostTier] = &HostTier::ALL;
128
129 /// A method open to both tiers. Reserve one for a tier with struct update:
130 /// `MethodSpec { tiers: &[HostTier::Builtin], ..row(..) }`.
131 const fn row(direction: Direction, name: &'static str, request: bool) -> MethodSpec {
132 MethodSpec {
133 name,
134 direction,
135 request,
136 tiers: ALL_TIERS,
137 }
138 }
139
140 /// The whole protocol surface. A method missing here is refused by both
141 /// parsers; a method added here must pass the authority lint.
142 pub const METHODS: &[MethodSpec] = &[
143 row(Direction::CoreToHost, "host/initialize", true),
144 row(Direction::CoreToHost, "host/ping", true),
145 row(Direction::CoreToHost, "host/shutdown", true),
146 row(Direction::CoreToHost, "ext/activate", true),
147 row(Direction::CoreToHost, "ext/deactivate", true),
148 row(Direction::CoreToHost, "tool/call", true),
149 row(Direction::CoreToHost, "command/run", true),
150 row(Direction::CoreToHost, "hook/evaluate", true),
151 MethodSpec {
152 tiers: &[HostTier::Builtin],
153 ..row(Direction::CoreToHost, "mcp/open", true)
154 },
155 MethodSpec {
156 tiers: &[HostTier::Builtin],
157 ..row(Direction::CoreToHost, "mcp/request", true)
158 },
159 MethodSpec {
160 tiers: &[HostTier::Builtin],
161 ..row(Direction::CoreToHost, "mcp/close", true)
162 },
163 MethodSpec {
164 tiers: &[HostTier::Builtin],
165 ..row(Direction::CoreToHost, "harness/run", true)
166 },
167 row(Direction::CoreToHost, "$/cancel", false),
168 row(Direction::HostToCore, "host/hello", false),
169 row(Direction::HostToCore, "host/ready", false),
170 row(Direction::HostToCore, "registry/register", true),
171 row(Direction::HostToCore, "registry/unregister", true),
172 row(Direction::HostToCore, "core/call", true),
173 MethodSpec {
174 tiers: &[HostTier::Builtin],
175 ..row(Direction::HostToCore, "proc/launch", true)
176 },
177 MethodSpec {
178 tiers: &[HostTier::Builtin],
179 ..row(Direction::HostToCore, "proc/read", true)
180 },
181 MethodSpec {
182 tiers: &[HostTier::Builtin],
183 ..row(Direction::HostToCore, "proc/write", true)
184 },
185 MethodSpec {
186 tiers: &[HostTier::Builtin],
187 ..row(Direction::HostToCore, "proc/close", true)
188 },
189 MethodSpec {
190 tiers: &[HostTier::Builtin],
191 ..row(Direction::HostToCore, "net/start", true)
192 },
193 MethodSpec {
194 tiers: &[HostTier::Builtin],
195 ..row(Direction::HostToCore, "net/fetch", true)
196 },
197 MethodSpec {
198 tiers: &[HostTier::Builtin],
199 ..row(Direction::HostToCore, "net/read", true)
200 },
201 MethodSpec {
202 tiers: &[HostTier::Builtin],
203 ..row(Direction::HostToCore, "net/release", true)
204 },
205 MethodSpec {
206 tiers: &[HostTier::Builtin],
207 ..row(Direction::HostToCore, "net/close", true)
208 },
209 MethodSpec {
210 tiers: &[HostTier::Builtin],
211 ..row(Direction::HostToCore, "exec/redeem", true)
212 },
213 row(Direction::HostToCore, "ext/faulted", false),
214 row(Direction::HostToCore, "log", false),
215 row(Direction::HostToCore, "$/cancel", false),
216 ];
217
218 /// Admit `method` travelling in `direction` from [`METHODS`]: anything not
219 /// in the table is refused, a request must carry an id and a notification
220 /// must not. Returns the id.
221 fn admit(
222 direction: Direction,
223 method: &str,
224 id: Option<u64>,
225 tier: HostTier,
226 ) -> Result<Option<u64>, ProtocolError> {
227 admit_in(METHODS, direction, method, id, tier)
228 }
229
230 /// [`admit`] over `table`, so the tier rule is testable with a reserved row
231 /// that production does not have.
232 fn admit_in(
233 table: &[MethodSpec],
234 direction: Direction,
235 method: &str,
236 id: Option<u64>,
237 tier: HostTier,
238 ) -> Result<Option<u64>, ProtocolError> {
239 let spec = table
240 .iter()
241 .find(|spec| spec.direction == direction && spec.name == method)
242 .ok_or_else(|| perr(format!("unknown {} method `{method}`", direction.as_str())))?;
243 if !spec.tiers.contains(&tier) {
244 return Err(perr(format!(
245 "`{method}` is not allowed on the {} tier",
246 tier.name()
247 )));
248 }
249 match (spec.request, id) {
250 (true, Some(_)) | (false, None) => Ok(id),
251 (true, None) => Err(perr(format!("`{method}` must be a request (with id)"))),
252 (false, Some(_)) => Err(perr(format!("`{method}` must be a notification (no id)"))),
253 }
254 }
255
256 /// Whether a host of `tier` may be sent (core to host) or send (host to core)
257 /// `method`. A method not in [`METHODS`] is not allowed.
258 #[must_use]
259 pub fn allowed_on(direction: Direction, method: &str, tier: HostTier) -> bool {
260 allowed_in(METHODS, direction, method, tier)
261 }
262
263 fn allowed_in(table: &[MethodSpec], direction: Direction, method: &str, tier: HostTier) -> bool {
264 table.iter().any(|spec| {
265 spec.direction == direction && spec.name == method && spec.tiers.contains(&tier)
266 })
267 }
268
269 fn undecoded(method: &str) -> ProtocolError {
270 perr(format!(
271 "`{method}` is in the method table but has no decoder"
272 ))
273 }
274
275 #[derive(Debug, thiserror::Error)]
276 pub enum FrameError {
277 #[error("bad frame magic")]
278 BadMagic,
279 #[error("frame of {0} bytes exceeds MAX_FRAME")]
280 TooLarge(usize),
281 #[error("frame payload is not JSON: {0}")]
282 Json(#[from] serde_json::Error),
283 #[error("channel read failed: {0}")]
284 Io(#[from] std::io::Error),
285 }
286
287 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
288 #[error("{0}")]
289 pub struct ProtocolError(pub String);
290
291 fn perr(message: impl Into<String>) -> ProtocolError {
292 ProtocolError(message.into())
293 }
294
295 /// Encode one message into a `CWX1` frame.
296 pub fn encode_frame(message: &Value) -> Result<Vec<u8>, FrameError> {
297 let payload = serde_json::to_vec(message)?;
298 if payload.len() > MAX_FRAME {
299 return Err(FrameError::TooLarge(payload.len()));
300 }
301 let mut frame = Vec::with_capacity(HEADER_LEN + payload.len());
302 frame.extend_from_slice(&MAGIC);
303 frame.extend_from_slice(&(payload.len() as u32).to_le_bytes());
304 frame.extend_from_slice(&payload);
305 Ok(frame)
306 }
307
308 /// Read one frame. `Ok(None)` is a clean EOF at a frame boundary. The payload
309 /// buffer is allocated only after the length is checked against `MAX_FRAME`.
310 pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Option<Value>, FrameError> {
311 let mut header = [0u8; HEADER_LEN];
312 let mut filled = 0;
313 while filled < HEADER_LEN {
314 let read = reader.read(&mut header[filled..]).await?;
315 if read == 0 {
316 if filled == 0 {
317 return Ok(None);
318 }
319 return Err(FrameError::Io(std::io::ErrorKind::UnexpectedEof.into()));
320 }
321 filled += read;
322 }
323 if header[..4] != MAGIC {
324 return Err(FrameError::BadMagic);
325 }
326 let length = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize;
327 if length > MAX_FRAME {
328 return Err(FrameError::TooLarge(length));
329 }
330 let mut payload = vec![0u8; length];
331 reader.read_exact(&mut payload).await?;
332 Ok(Some(serde_json::from_slice(&payload)?))
333 }
334
335 /// `Some(value)` even for an explicit JSON `null`, so `"result": null` is a
336 /// present result and round-trips.
337 fn present_entry<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<EntryRef>, D::Error> {
338 EntryRef::deserialize(deserializer).map(Some)
339 }
340 fn present<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Value>, D::Error> {
341 Value::deserialize(deserializer).map(Some)
342 }
343
344 // ---------------------------------------------------------------------------
345 // Shared types
346 // ---------------------------------------------------------------------------
347
348 /// Identity of one activation. `generation` bumps on every (re)activation;
349 /// `owner_token` is 128+ random bits minted by the core and handed over only
350 /// in `ext/activate`. It catches bugs and stale fibers; it is not a boundary
351 /// against a malicious plugin in the same process (see the design, §4.4).
352 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
353 #[cfg_attr(test, derive(schemars::JsonSchema))]
354 #[serde(deny_unknown_fields)]
355 pub struct OwnerRef {
356 pub plugin_id: String,
357 pub generation: u64,
358 pub owner_token: String,
359 }
360
361 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362 #[cfg_attr(test, derive(schemars::JsonSchema))]
363 #[serde(deny_unknown_fields)]
364 pub struct RpcErrorWire {
365 pub code: i64,
366 pub message: String,
367 #[serde(
368 default,
369 skip_serializing_if = "Option::is_none",
370 deserialize_with = "present"
371 )]
372 pub data: Option<Value>,
373 }
374
375 // ---------------------------------------------------------------------------
376 // Host → core (strict)
377 // ---------------------------------------------------------------------------
378
379 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380 #[cfg_attr(test, derive(schemars::JsonSchema))]
381 #[serde(deny_unknown_fields)]
382 pub struct ProtocolRange {
383 pub min: u32,
384 pub max: u32,
385 }
386
387 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388 #[cfg_attr(test, derive(schemars::JsonSchema))]
389 #[serde(deny_unknown_fields)]
390 pub struct HelloParams {
391 pub protocol: ProtocolRange,
392 pub host_version: String,
393 pub bundle_sha256: String,
394 /// The runtime actually running the host. Under Bun this comes from
395 /// `process.versions.bun`, not the Node version Bun emulates.
396 pub runtime: HelloRuntime,
397 /// The trust tier this host process serves (`--tier=`). The core refuses a
398 /// host that reports any tier but the one it launched.
399 pub tier: HostTier,
400 /// The SHA-256 of every built-in module source this host build embeds
401 /// (`dist/builtin-modules.json`), one row per module, in id order. The
402 /// core refuses a host whose list is not the one its own table pins.
403 pub builtin_modules: Vec<ModuleDigestWire>,
404 /// A kernel memory limit the host applied to itself before loading any
405 /// plugin, in MiB: macOS + Bun, when the core asked for one
406 /// (`supervisor::MemoryEnforcement::Jetsam`). Absent otherwise.
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub memory_limit_mib: Option<u64>,
409 }
410
411 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412 #[cfg_attr(test, derive(schemars::JsonSchema))]
413 #[serde(deny_unknown_fields)]
414 pub struct HelloRuntime {
415 /// `bun` or `node`.
416 pub name: String,
417 pub version: String,
418 }
419
420 /// One built-in module's pinned source digest, as the host build records it.
421 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422 #[cfg_attr(test, derive(schemars::JsonSchema))]
423 #[serde(deny_unknown_fields)]
424 pub struct ModuleDigestWire {
425 pub id: String,
426 pub sha256: String,
427 }
428
429 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
430 #[cfg_attr(test, derive(schemars::JsonSchema))]
431 #[serde(deny_unknown_fields)]
432 pub struct EmptyParams {}
433
434 /// What a registration is: a `tool` the model calls (`tool/call`), or a slash
435 /// `command` the user runs (`/name args`, `command/run`). No variant docs:
436 /// schemars would then render the enum as a mix the generator does not read.
437 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438 #[cfg_attr(test, derive(schemars::JsonSchema))]
439 #[serde(rename_all = "snake_case")]
440 pub enum RegisterKind {
441 Tool,
442 Command,
443 Hook,
444 PromptSection,
445 PromptTemplate,
446 SkillRoot,
447 ShellHook,
448 McpServer,
449 }
450
451 /// What a registration proposes. The fields a kind uses are fixed by
452 /// [`RegisterParams::check_spec`] (the generated shapes cannot express a
453 /// per-kind union): a tool has an `input_schema` and no `argument_hint`; a
454 /// command has no `input_schema` and may have an `argument_hint`.
455 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
456 #[cfg_attr(test, derive(schemars::JsonSchema))]
457 #[serde(deny_unknown_fields)]
458 pub struct RegisterSpecWire {
459 pub name: String,
460 pub description: String,
461 /// Tools only: the JSON object schema of the call's input.
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub input_schema: Option<Map<String, Value>>,
464 /// Commands only: the argument placeholder shown after the name (for
465 /// example `<topic>`). A command with a hint waits in the composer for
466 /// arguments; one without runs directly from the palette.
467 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub argument_hint: Option<String>,
469 }
470
471 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472 #[cfg_attr(test, derive(schemars::JsonSchema))]
473 #[serde(deny_unknown_fields)]
474 pub struct RegisterParams {
475 pub owner: OwnerRef,
476 #[serde(
477 default,
478 skip_serializing_if = "Option::is_none",
479 deserialize_with = "present_entry"
480 )]
481 pub scope: Option<EntryRef>,
482 pub kind: RegisterKind,
483 pub spec: RegisterSpecWire,
484 }
485
486 impl RegisterParams {
487 /// The rule [`RegisterSpecWire`]'s docs state: which spec fields each
488 /// kind uses. Applied by [`parse_host_message`] and mirrored in the
489 /// host's `validateMessage`; the corpus holds both to it.
490 pub fn check_spec(&self) -> Result<(), String> {
491 let spec = &self.spec;
492 match self.kind {
493 RegisterKind::Tool if spec.input_schema.is_none() => {
494 Err("a tool registration needs `spec.input_schema`".to_string())
495 }
496 RegisterKind::Tool if spec.argument_hint.is_some() => {
497 Err("a tool registration has no `spec.argument_hint`".to_string())
498 }
499 RegisterKind::Command if spec.input_schema.is_some() => {
500 Err("a command registration has no `spec.input_schema`".to_string())
501 }
502 RegisterKind::Hook | RegisterKind::PromptSection | RegisterKind::PromptTemplate | RegisterKind::SkillRoot | RegisterKind::ShellHook | RegisterKind::McpServer
503 if spec.input_schema.is_some() || spec.argument_hint.is_some() =>
504 {
505 Err(
506 "a hook, prompt or skill root registration has no input schema or argument hint"
507 .to_string(),
508 )
509 }
510 _ => Ok(()),
511 }
512 }
513 }
514
515 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
516 #[cfg_attr(test, derive(schemars::JsonSchema))]
517 #[serde(deny_unknown_fields)]
518 pub struct UnregisterParams {
519 pub owner: OwnerRef,
520 pub handle: u64,
521 }
522
523 /// A tool asking the core to run one of the core's tools for it
524 /// (`core/call`, answered with a [`ToolResultWire`]). The `ticket` is the
525 /// invocation ticket the core put in this call's `tool/call`; the core checks
526 /// it against its own row (owner, tier, host generation, method, limits) and
527 /// the host never decides anything: policy, approval and the approval card's
528 /// text are the core's.
529 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530 #[cfg_attr(test, derive(schemars::JsonSchema))]
531 #[serde(deny_unknown_fields)]
532 pub struct CoreCallParams {
533 pub owner: OwnerRef,
534 pub ticket: String,
535 pub name: String,
536 pub input: Value,
537 }
538
539 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540 #[cfg_attr(test, derive(schemars::JsonSchema))]
541 #[serde(deny_unknown_fields)]
542 pub struct FaultedParams {
543 pub owner: OwnerRef,
544 pub error: String,
545 }
546
547 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
548 #[cfg_attr(test, derive(schemars::JsonSchema))]
549 #[serde(deny_unknown_fields)]
550 pub struct LogParams {
551 pub level: String,
552 pub msg: String,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub plugin_id: Option<String>,
555 }
556
557 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
558 #[cfg_attr(test, derive(schemars::JsonSchema))]
559 #[serde(deny_unknown_fields)]
560 pub struct CancelParams {
561 pub id: u64,
562 }
563
564 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
565 #[cfg_attr(test, derive(schemars::JsonSchema))]
566 #[serde(deny_unknown_fields)]
567 pub struct HookDispatchWire {
568 pub event: String,
569 pub dialect: String,
570 pub point: String,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub matcher: Option<String>,
573 pub query: String,
574 }
575
576 // Opaque execution references only. Rust never sends commands, inputs, environment or credentials.
577 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578 #[cfg_attr(test, derive(schemars::JsonSchema))]
579 #[serde(deny_unknown_fields)]
580 pub struct HarnessRunParams {
581 pub owner: OwnerRef,
582 pub execution_id: String,
583 pub ticket: String,
584 pub deadline_ms: u64,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub hook: Option<HookDispatchWire>,
587 }
588 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589 #[cfg_attr(test, derive(schemars::JsonSchema))]
590 #[serde(deny_unknown_fields)]
591 pub struct ExecutionRedeemParams {
592 pub owner: OwnerRef,
593 pub execution_id: String,
594 pub ticket: String,
595 }
596
597 // Tier-0 MCP semantic operations and broker pipe access. Launch details and
598 // Computer Use keys are Rust-only; every write is an exact single-use grant.
599 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600 #[cfg_attr(test, derive(schemars::JsonSchema))]
601 #[serde(deny_unknown_fields)]
602 pub struct McpOperationGrant {
603 pub ticket: String,
604 pub operation_id: String,
605 pub method: String,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub wire_id: Option<String>,
608 pub params: Value,
609 }
610
611 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612 #[cfg_attr(test, derive(schemars::JsonSchema))]
613 #[serde(deny_unknown_fields)]
614 pub struct McpOpenParams {
615 pub owner: OwnerRef,
616 pub session_id: String,
617 pub launch_ticket: String,
618 pub transport: String,
619 pub initialize_grant: McpOperationGrant,
620 pub initialized_grant: McpOperationGrant,
621 pub client_version: String,
622 pub deadline_ms: u64,
623 }
624
625 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
626 #[cfg_attr(test, derive(schemars::JsonSchema))]
627 #[serde(deny_unknown_fields)]
628 pub struct McpRequestParams {
629 pub owner: OwnerRef,
630 pub session_id: String,
631 pub grant: McpOperationGrant,
632 pub deadline_ms: u64,
633 }
634
635 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636 #[cfg_attr(test, derive(schemars::JsonSchema))]
637 #[serde(deny_unknown_fields)]
638 pub struct McpCloseParams {
639 pub owner: OwnerRef,
640 pub session_id: String,
641 pub deadline_ms: u64,
642 }
643
644 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645 #[cfg_attr(test, derive(schemars::JsonSchema))]
646 #[serde(deny_unknown_fields)]
647 pub struct ProcLaunchParams {
648 pub owner: OwnerRef,
649 pub session_id: String,
650 pub ticket: String,
651 }
652
653 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
654 #[cfg_attr(test, derive(schemars::JsonSchema))]
655 #[serde(deny_unknown_fields)]
656 pub struct ProcSessionParams {
657 pub owner: OwnerRef,
658 pub session_id: String,
659 }
660
661 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662 #[cfg_attr(test, derive(schemars::JsonSchema))]
663 #[serde(deny_unknown_fields)]
664 pub struct ProcWriteParams {
665 pub owner: OwnerRef,
666 pub session_id: String,
667 pub frame: Value,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub ticket: Option<String>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub operation_id: Option<String>,
672 }
673
674 /// The SDK can propose framing headers only. Rust adds credentials itself.
675 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
676 #[cfg_attr(test, derive(schemars::JsonSchema))]
677 #[serde(deny_unknown_fields)]
678 pub struct McpHttpHeaders {
679 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub accept: Option<String>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub content_type: Option<String>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub mcp_session_id: Option<String>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub mcp_protocol_version: Option<String>,
687 }
688
689 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690 #[cfg_attr(test, derive(schemars::JsonSchema))]
691 #[serde(deny_unknown_fields)]
692 pub struct NetFetchParams {
693 pub owner: OwnerRef,
694 pub session_id: String,
695 pub url: String,
696 pub method: String,
697 pub headers: McpHttpHeaders,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub frame: Option<Value>,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub ticket: Option<String>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub operation_id: Option<String>,
704 }
705 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
706 #[cfg_attr(test, derive(schemars::JsonSchema))]
707 #[serde(deny_unknown_fields)]
708 pub struct NetReadParams {
709 pub owner: OwnerRef,
710 pub session_id: String,
711 pub response_id: String,
712 }
713
714 #[derive(Debug, Clone, PartialEq)]
715 pub enum HostRequest {
716 Register(RegisterParams),
717 Unregister(UnregisterParams),
718 CoreCall(CoreCallParams),
719 ExecutionRedeem(ExecutionRedeemParams),
720 ProcClose(ProcSessionParams),
721 NetStart(ProcLaunchParams),
722 NetFetch(NetFetchParams),
723 NetRead(NetReadParams),
724 NetRelease(NetReadParams),
725 NetClose(ProcSessionParams),
726 ProcWrite(ProcWriteParams),
727 ProcRead(ProcSessionParams),
728 ProcLaunch(ProcLaunchParams),
729 }
730
731 impl HostRequest {
732 /// The plugin whose revocation cancels this request in flight.
733 #[must_use]
734 pub fn plugin_id(&self) -> &str {
735 match self {
736 Self::Register(params) => &params.owner.plugin_id,
737 Self::Unregister(params) => &params.owner.plugin_id,
738 Self::CoreCall(params) => &params.owner.plugin_id,
739 Self::ExecutionRedeem(params) => &params.owner.plugin_id,
740 Self::ProcClose(params) => &params.owner.plugin_id,
741 Self::NetClose(params) => &params.owner.plugin_id,
742 Self::NetRelease(params) => &params.owner.plugin_id,
743 Self::NetRead(params) => &params.owner.plugin_id,
744 Self::NetFetch(params) => &params.owner.plugin_id,
745 Self::NetStart(params) => &params.owner.plugin_id,
746 Self::ProcWrite(params) => &params.owner.plugin_id,
747 Self::ProcRead(params) => &params.owner.plugin_id,
748 Self::ProcLaunch(params) => &params.owner.plugin_id,
749 }
750 }
751 }
752
753 #[derive(Debug, Clone, PartialEq)]
754 pub enum HostNotification {
755 Hello(HelloParams),
756 Ready,
757 Faulted(FaultedParams),
758 Log(LogParams),
759 Cancel(CancelParams),
760 }
761
762 /// One decoded, validated host→core message.
763 #[derive(Debug, Clone, PartialEq)]
764 pub enum HostMessage {
765 Request {
766 id: u64,
767 request: HostRequest,
768 },
769 Notification(HostNotification),
770 Response {
771 id: u64,
772 outcome: Result<Value, RpcErrorWire>,
773 },
774 }
775
776 #[derive(Deserialize)]
777 #[serde(deny_unknown_fields)]
778 struct Envelope {
779 jsonrpc: String,
780 #[serde(default)]
781 id: Option<u64>,
782 #[serde(default)]
783 method: Option<String>,
784 #[serde(default, deserialize_with = "present")]
785 params: Option<Value>,
786 #[serde(default, deserialize_with = "present")]
787 result: Option<Value>,
788 #[serde(default)]
789 error: Option<RpcErrorWire>,
790 }
791
792 fn decode_envelope(value: Value) -> Result<Envelope, ProtocolError> {
793 let envelope: Envelope =
794 serde_json::from_value(value).map_err(|e| perr(format!("envelope: {e}")))?;
795 if envelope.jsonrpc != "2.0" {
796 return Err(perr("envelope: jsonrpc must be \"2.0\""));
797 }
798 Ok(envelope)
799 }
800
801 /// Params are always named: an array would otherwise decode positionally
802 /// into a struct (`[]` into [`EmptyParams`]), which the TypeScript side
803 /// rejects.
804 fn params<T: DeserializeOwned>(method: &str, params: Option<Value>) -> Result<T, ProtocolError> {
805 let params = params.unwrap_or_else(|| json!({}));
806 if !params.is_object() {
807 return Err(perr(format!("{method}: params must be an object")));
808 }
809 serde_json::from_value(params).map_err(|e| perr(format!("{method}: {e}")))
810 }
811
812 fn decode_response(
813 envelope: Envelope,
814 ) -> Result<(u64, Result<Value, RpcErrorWire>), ProtocolError> {
815 let id = envelope.id.ok_or_else(|| perr("response: missing id"))?;
816 if envelope.params.is_some() {
817 return Err(perr("response: unexpected params"));
818 }
819 match (envelope.result, envelope.error) {
820 (Some(result), None) => Ok((id, Ok(result))),
821 (None, Some(error)) => Ok((id, Err(error))),
822 _ => Err(perr("response: needs exactly one of result or error")),
823 }
824 }
825
826 /// Parse and strictly validate one host→core message from a host of `tier`: a
827 /// method reserved for another tier is refused like an unknown one.
828 pub fn parse_host_message(value: Value, tier: HostTier) -> Result<HostMessage, ProtocolError> {
829 let envelope = decode_envelope(value)?;
830 let Some(method) = envelope.method.clone() else {
831 let (id, outcome) = decode_response(envelope)?;
832 return Ok(HostMessage::Response { id, outcome });
833 };
834 if envelope.result.is_some() || envelope.error.is_some() {
835 return Err(perr(format!(
836 "`{method}`: a request carries no result or error"
837 )));
838 }
839 let id = admit(Direction::HostToCore, &method, envelope.id, tier)?;
840 let p = envelope.params;
841 let message = match (method.as_str(), id) {
842 ("registry/register", Some(id)) => {
843 let register: RegisterParams = params(&method, p)?;
844 register
845 .check_spec()
846 .map_err(|reason| perr(format!("{method}: {reason}")))?;
847 HostMessage::Request {
848 id,
849 request: HostRequest::Register(register),
850 }
851 }
852 ("registry/unregister", Some(id)) => HostMessage::Request {
853 id,
854 request: HostRequest::Unregister(params(&method, p)?),
855 },
856 ("core/call", Some(id)) => HostMessage::Request {
857 id,
858 request: HostRequest::CoreCall(params(&method, p)?),
859 },
860 ("net/start", Some(id)) => HostMessage::Request {
861 id,
862 request: HostRequest::NetStart(params(&method, p)?),
863 },
864 ("net/fetch", Some(id)) => HostMessage::Request {
865 id,
866 request: HostRequest::NetFetch(params(&method, p)?),
867 },
868 ("net/read", Some(id)) => HostMessage::Request {
869 id,
870 request: HostRequest::NetRead(params(&method, p)?),
871 },
872 ("net/release", Some(id)) => HostMessage::Request {
873 id,
874 request: HostRequest::NetRelease(params(&method, p)?),
875 },
876 ("net/close", Some(id)) => HostMessage::Request {
877 id,
878 request: HostRequest::NetClose(params(&method, p)?),
879 },
880 ("proc/close", Some(id)) => HostMessage::Request {
881 id,
882 request: HostRequest::ProcClose(params(&method, p)?),
883 },
884 ("proc/write", Some(id)) => HostMessage::Request {
885 id,
886 request: HostRequest::ProcWrite(params(&method, p)?),
887 },
888 ("proc/read", Some(id)) => HostMessage::Request {
889 id,
890 request: HostRequest::ProcRead(params(&method, p)?),
891 },
892 ("exec/redeem", Some(id)) => HostMessage::Request {
893 id,
894 request: HostRequest::ExecutionRedeem(params(&method, p)?),
895 },
896 ("proc/launch", Some(id)) => HostMessage::Request {
897 id,
898 request: HostRequest::ProcLaunch(params(&method, p)?),
899 },
900 ("host/hello", None) => {
901 let hello: HelloParams = params(&method, p)?;
902 if !matches!(hello.runtime.name.as_str(), "bun" | "node") {
903 return Err(perr(format!(
904 "host/hello.runtime.name: unknown runtime `{}`",
905 hello.runtime.name
906 )));
907 }
908 HostMessage::Notification(HostNotification::Hello(hello))
909 }
910 ("host/ready", None) => {
911 let _: EmptyParams = params(&method, p)?;
912 HostMessage::Notification(HostNotification::Ready)
913 }
914 ("ext/faulted", None) => {
915 HostMessage::Notification(HostNotification::Faulted(params(&method, p)?))
916 }
917 ("log", None) => HostMessage::Notification(HostNotification::Log(params(&method, p)?)),
918 ("$/cancel", None) => {
919 HostMessage::Notification(HostNotification::Cancel(params(&method, p)?))
920 }
921 _ => return Err(undecoded(&method)),
922 };
923 Ok(message)
924 }
925
926 fn request_value(id: u64, method: &str, params: Value) -> Value {
927 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
928 }
929
930 fn notification_value(method: &str, params: Value) -> Value {
931 json!({"jsonrpc": "2.0", "method": method, "params": params})
932 }
933
934 pub fn response_value(id: u64, outcome: &Result<Value, RpcErrorWire>) -> Value {
935 match outcome {
936 Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
937 Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}),
938 }
939 }
940
941 fn to_value<T: Serialize>(value: &T) -> Value {
942 serde_json::to_value(value).expect("protocol types serialize")
943 }
944
945 #[cfg(test)]
946 impl HostMessage {
947 /// Re-encode (used by the conformance corpus round-trip).
948 #[must_use]
949 pub fn to_value(&self) -> Value {
950 match self {
951 Self::Request { id, request } => match request {
952 HostRequest::Register(p) => request_value(*id, "registry/register", to_value(p)),
953 HostRequest::Unregister(p) => {
954 request_value(*id, "registry/unregister", to_value(p))
955 }
956 HostRequest::ExecutionRedeem(p) => request_value(*id, "exec/redeem", to_value(p)),
957 HostRequest::CoreCall(p) => request_value(*id, "core/call", to_value(p)),
958 HostRequest::ProcClose(p) => request_value(*id, "proc/close", to_value(p)),
959 HostRequest::NetStart(p) => request_value(*id, "net/start", to_value(p)),
960 HostRequest::NetFetch(p) => request_value(*id, "net/fetch", to_value(p)),
961 HostRequest::NetRead(p) => request_value(*id, "net/read", to_value(p)),
962 HostRequest::NetRelease(p) => request_value(*id, "net/release", to_value(p)),
963 HostRequest::NetClose(p) => request_value(*id, "net/close", to_value(p)),
964
965 HostRequest::ProcWrite(p) => request_value(*id, "proc/write", to_value(p)),
966 HostRequest::ProcRead(p) => request_value(*id, "proc/read", to_value(p)),
967 HostRequest::ProcLaunch(p) => request_value(*id, "proc/launch", to_value(p)),
968 },
969 Self::Notification(notification) => match notification {
970 HostNotification::Hello(p) => notification_value("host/hello", to_value(p)),
971 HostNotification::Ready => notification_value("host/ready", json!({})),
972 HostNotification::Faulted(p) => notification_value("ext/faulted", to_value(p)),
973 HostNotification::Log(p) => notification_value("log", to_value(p)),
974 HostNotification::Cancel(p) => notification_value("$/cancel", to_value(p)),
975 },
976 Self::Response { id, outcome } => response_value(*id, outcome),
977 }
978 }
979 }
980
981 // ---------------------------------------------------------------------------
982 // Core → host (written by this side)
983 // ---------------------------------------------------------------------------
984
985 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
986 #[cfg_attr(test, derive(schemars::JsonSchema))]
987 pub struct HostLimits {
988 pub max_frame: u64,
989 pub max_inflight: u64,
990 pub dispose_deadline_ms: u64,
991 pub activate_deadline_ms: u64,
992 }
993
994 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
995 #[cfg_attr(test, derive(schemars::JsonSchema))]
996 pub struct InitializeParams {
997 pub protocol: u32,
998 pub limits: HostLimits,
999 }
1000
1001 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1002 #[cfg_attr(test, derive(schemars::JsonSchema))]
1003 pub struct EntryRef {
1004 pub path: String,
1005 pub sha256: String,
1006 }
1007
1008 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1009 #[cfg_attr(test, derive(schemars::JsonSchema))]
1010 pub struct ActivateParams {
1011 pub owner: OwnerRef,
1012 #[serde(
1013 default,
1014 skip_serializing_if = "Option::is_none",
1015 deserialize_with = "present_entry"
1016 )]
1017 pub scope: Option<EntryRef>,
1018 pub plugin_name: String,
1019 pub entry: EntryRef,
1020 /// The plugin's settings (`[plugins."<name>".config]`), delivered as the
1021 /// second argument of `apply`. Always an object; `{}` when none.
1022 #[serde(default = "empty_object")]
1023 pub config: Value,
1024 /// The plugin's own writable directory (read-only string to the plugin).
1025 /// The same for every entry of one owner.
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub data_dir: Option<String>,
1028 }
1029
1030 fn empty_object() -> Value {
1031 json!({})
1032 }
1033
1034 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1035 #[cfg_attr(test, derive(schemars::JsonSchema))]
1036 pub struct DeactivateParams {
1037 pub owner: OwnerRef,
1038 #[serde(
1039 default,
1040 skip_serializing_if = "Option::is_none",
1041 deserialize_with = "present_entry"
1042 )]
1043 pub entry: Option<EntryRef>,
1044 }
1045
1046 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047 #[cfg_attr(test, derive(schemars::JsonSchema))]
1048 pub struct ToolCallParams {
1049 pub handle: u64,
1050 pub call_id: String,
1051 pub input: Value,
1052 pub deadline_ms: u64,
1053 /// The workspace of the session the call comes from, and no other. Absent
1054 /// when its path is not valid UTF-8.
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub workspace: Option<String>,
1057 /// The invocation ticket: present only when this call runs under the turn
1058 /// loop's permission gate, and what `core/call` must present. Absent for a
1059 /// call with no gate (a sub-agent, one nested in `execute_tools`, a test),
1060 /// whose tool then has no way to ask the core for anything.
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub ticket: Option<String>,
1063 /// Per-invocation identity; never cached at activation or by the host root.
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub session_id: Option<String>,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub agent_id: Option<String>,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub origin_turn_id: Option<String>,
1070 }
1071
1072 /// One user invocation of a registered command. `raw_input` is what follows
1073 /// the command name, trimmed (the core's slash-command parser trims it).
1074 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1075 #[cfg_attr(test, derive(schemars::JsonSchema))]
1076 pub struct CommandRunParams {
1077 pub handle: u64,
1078 pub command_id: String,
1079 pub raw_input: String,
1080 pub deadline_ms: u64,
1081 /// The workspace the user ran the command in, and no other.
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub workspace: Option<String>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub session_id: Option<String>,
1086 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub agent_id: Option<String>,
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1089 pub origin_turn_id: Option<String>,
1090 }
1091
1092 /// A Rust-composed view of a pending call. No session handle or invocation
1093 /// ticket crosses this boundary.
1094 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1095 #[cfg_attr(test, derive(schemars::JsonSchema))]
1096 #[serde(deny_unknown_fields)]
1097 pub struct HookCallPayload {
1098 pub name: String,
1099 pub call_id: String,
1100 pub input: Value,
1101 pub mode: String,
1102 pub workspace: String,
1103 pub model: String,
1104 }
1105
1106 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1107 #[cfg_attr(test, derive(schemars::JsonSchema))]
1108 #[serde(deny_unknown_fields)]
1109 pub struct HookEvaluateParams {
1110 pub handle: u64,
1111 pub event: String,
1112 pub payload: HookCallPayload,
1113 pub deadline_ms: u64,
1114 }
1115
1116 /// Monotonic proposals. Rust folds them with native hooks and re-prepares
1117 /// revised input through every policy and approval gate.
1118 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1119 #[cfg_attr(test, derive(schemars::JsonSchema))]
1120 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1121 pub enum HookVerdictWire {
1122 Abstain,
1123 Deny { reason: String },
1124 Ask { reason: String },
1125 Annotate { text: String },
1126 Revise { input: Map<String, Value> },
1127 }
1128
1129 #[derive(Debug, Clone, PartialEq)]
1130 pub enum CoreRequest {
1131 Ping,
1132 Initialize(InitializeParams),
1133 /// Production relies on stdin EOF at process exit (the host is shared by
1134 /// every engine in the process); the bounded shutdown is test-driven.
1135 #[cfg(test)]
1136 Shutdown,
1137 Activate(ActivateParams),
1138 Deactivate(DeactivateParams),
1139 ToolCall(ToolCallParams),
1140 CommandRun(CommandRunParams),
1141 HookEvaluate(HookEvaluateParams),
1142 HarnessRun(HarnessRunParams),
1143 McpClose(McpCloseParams),
1144 McpRequest(McpRequestParams),
1145 McpOpen(Box<McpOpenParams>),
1146 }
1147
1148 impl CoreRequest {
1149 #[must_use]
1150 pub fn method(&self) -> &'static str {
1151 match self {
1152 Self::Ping => "host/ping",
1153 Self::Initialize(_) => "host/initialize",
1154 #[cfg(test)]
1155 Self::Shutdown => "host/shutdown",
1156 Self::Activate(_) => "ext/activate",
1157 Self::Deactivate(_) => "ext/deactivate",
1158 Self::ToolCall(_) => "tool/call",
1159 Self::CommandRun(_) => "command/run",
1160 Self::HookEvaluate(_) => "hook/evaluate",
1161 Self::HarnessRun(_) => "harness/run",
1162 Self::McpClose(_) => "mcp/close",
1163 Self::McpRequest(_) => "mcp/request",
1164 Self::McpOpen(_) => "mcp/open",
1165 }
1166 }
1167
1168 #[must_use]
1169 pub fn params(&self) -> Value {
1170 match self {
1171 Self::Ping => json!({}),
1172 Self::Initialize(p) => to_value(p),
1173 #[cfg(test)]
1174 Self::Shutdown => json!({}),
1175 Self::Activate(p) => to_value(p),
1176 Self::Deactivate(p) => to_value(p),
1177 Self::ToolCall(p) => to_value(p),
1178 Self::CommandRun(p) => to_value(p),
1179 Self::HookEvaluate(p) => to_value(p),
1180 Self::HarnessRun(p) => to_value(p),
1181 Self::McpClose(p) => to_value(p),
1182 Self::McpRequest(p) => to_value(p),
1183 Self::McpOpen(p) => to_value(p),
1184 }
1185 }
1186
1187 /// How long the core waits for this request's answer before it sends
1188 /// `$/cancel`, forgets the call and fails it with
1189 /// `HostCallError::Timeout` (`HostProcess::call`). The match is
1190 /// exhaustive, so no request can be added without a deadline.
1191 ///
1192 /// | method | deadline | why |
1193 /// |---|---|---|
1194 /// | `host/initialize` | `HANDSHAKE_DEADLINE` (30 s) | the whole handshake has the same budget |
1195 /// | `host/ping` | `PING_DEADLINE` (10 s) | the heartbeat supervises pings with its own `ping_timeout`/`hang_timeout` and kills a silent host; this bounds any other caller |
1196 /// | `host/shutdown` | 2 s | tests only |
1197 /// | `ext/activate` | `ACTIVATE_DEADLINE` + 1 s | the host enforces activation's own deadline; 1 s for its answer to arrive |
1198 /// | `ext/deactivate` | `DISPOSE_DEADLINE` + 500 ms | the same, for disposal |
1199 /// | `tool/call` | its `deadline_ms` | the host is told the bound the core enforces (`SupervisionOptions::tool_call_deadline`, 120 s) |
1200 /// | `command/run` | its `deadline_ms` | the same, for a command the user is waiting on (`SupervisionOptions::command_run_deadline`, 30 s) |
1201 #[must_use]
1202 pub fn deadline(&self) -> Duration {
1203 use super::supervisor::{
1204 ACTIVATE_DEADLINE, DISPOSE_DEADLINE, HANDSHAKE_DEADLINE, PING_DEADLINE,
1205 };
1206 match self {
1207 Self::Ping => PING_DEADLINE,
1208 Self::Initialize(_) => HANDSHAKE_DEADLINE,
1209 #[cfg(test)]
1210 Self::Shutdown => Duration::from_secs(2),
1211 Self::Activate(_) => ACTIVATE_DEADLINE + Duration::from_secs(1),
1212 Self::Deactivate(_) => DISPOSE_DEADLINE + Duration::from_millis(500),
1213 Self::ToolCall(params) => Duration::from_millis(params.deadline_ms),
1214 Self::CommandRun(params) => Duration::from_millis(params.deadline_ms),
1215 Self::HookEvaluate(params) => Duration::from_millis(params.deadline_ms),
1216 Self::HarnessRun(params) => Duration::from_millis(params.deadline_ms),
1217 Self::McpClose(params) => Duration::from_millis(params.deadline_ms),
1218 Self::McpRequest(params) => Duration::from_millis(params.deadline_ms),
1219 Self::McpOpen(params) => Duration::from_millis(params.deadline_ms),
1220 }
1221 }
1222
1223 #[must_use]
1224 pub fn to_value(&self, id: u64) -> Value {
1225 request_value(id, self.method(), self.params())
1226 }
1227 }
1228
1229 /// `registry/register` answer: a handle, or a refusal the host reports as a
1230 /// failed activation.
1231 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1232 #[cfg_attr(test, derive(schemars::JsonSchema))]
1233 #[serde(untagged)]
1234 pub enum RegisterResult {
1235 Admitted { handle: u64 },
1236 Refused { refused: String },
1237 }
1238
1239 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1240 #[cfg_attr(test, derive(schemars::JsonSchema))]
1241 #[serde(tag = "status", rename_all = "snake_case")]
1242 pub enum ActivateResult {
1243 Ok {
1244 tools: Vec<String>,
1245 #[serde(default)]
1246 commands: Vec<String>,
1247 },
1248 Failed {
1249 diagnostic: String,
1250 },
1251 }
1252
1253 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1254 #[cfg_attr(test, derive(schemars::JsonSchema))]
1255 pub struct DeactivateResult {
1256 pub disposed: bool,
1257 pub leaked: Vec<String>,
1258 }
1259
1260 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1261 #[cfg_attr(test, derive(schemars::JsonSchema))]
1262 #[serde(tag = "type", rename_all = "snake_case")]
1263 pub enum ContentBlockWire {
1264 Text { text: String },
1265 }
1266
1267 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1268 #[cfg_attr(test, derive(schemars::JsonSchema))]
1269 pub struct ToolResultWire {
1270 pub content: Vec<ContentBlockWire>,
1271 pub is_error: bool,
1272 #[serde(
1273 default,
1274 skip_serializing_if = "Option::is_none",
1275 deserialize_with = "present"
1276 )]
1277 pub structured: Option<Value>,
1278 }
1279
1280 /// A command's answer. The core shows `text` to the user (stripped of
1281 /// terminal escapes and bounded) and, for `submit`, sends `prompt` as the
1282 /// user's next message through the ordinary turn: the model's work after that
1283 /// is gated like any other. `error` is shown as a failure.
1284 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1285 #[cfg_attr(test, derive(schemars::JsonSchema))]
1286 #[serde(tag = "kind", rename_all = "snake_case")]
1287 pub enum CommandResultWire {
1288 Success {
1289 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 text: Option<String>,
1291 },
1292 Error {
1293 text: String,
1294 },
1295 Submit {
1296 prompt: String,
1297 /// An optional note shown to the user alongside the submission.
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 text: Option<String>,
1300 },
1301 }
1302
1303 /// One core→host message, parsed back (tests and the corpus only).
1304 #[cfg(test)]
1305 #[derive(Debug, Clone, PartialEq)]
1306 pub enum CoreMessage {
1307 Request {
1308 id: u64,
1309 request: CoreRequest,
1310 },
1311 Cancel(CancelParams),
1312 Response {
1313 id: u64,
1314 outcome: Result<Value, RpcErrorWire>,
1315 },
1316 }
1317
1318 /// Parse a core→host message (the shape this side writes) bound for a host of
1319 /// `tier`.
1320 #[cfg(test)]
1321 pub fn parse_core_message(value: Value, tier: HostTier) -> Result<CoreMessage, ProtocolError> {
1322 let envelope = decode_envelope(value)?;
1323 let Some(method) = envelope.method.clone() else {
1324 let (id, outcome) = decode_response(envelope)?;
1325 return Ok(CoreMessage::Response { id, outcome });
1326 };
1327 let p = envelope.params;
1328 let Some(id) = admit(Direction::CoreToHost, &method, envelope.id, tier)? else {
1329 return match method.as_str() {
1330 "$/cancel" => Ok(CoreMessage::Cancel(params(&method, p)?)),
1331 _ => Err(undecoded(&method)),
1332 };
1333 };
1334 let request = match method.as_str() {
1335 "host/initialize" => CoreRequest::Initialize(params(&method, p)?),
1336 "host/shutdown" => {
1337 let _: EmptyParams = params(&method, p)?;
1338 CoreRequest::Shutdown
1339 }
1340 "host/ping" => {
1341 let _: EmptyParams = params(&method, p)?;
1342 CoreRequest::Ping
1343 }
1344 "ext/activate" => CoreRequest::Activate(params(&method, p)?),
1345 "ext/deactivate" => CoreRequest::Deactivate(params(&method, p)?),
1346 "tool/call" => CoreRequest::ToolCall(params(&method, p)?),
1347 "command/run" => CoreRequest::CommandRun(params(&method, p)?),
1348 "hook/evaluate" => CoreRequest::HookEvaluate(params(&method, p)?),
1349 "harness/run" => CoreRequest::HarnessRun(params(&method, p)?),
1350 "mcp/close" => CoreRequest::McpClose(params(&method, p)?),
1351 "mcp/request" => CoreRequest::McpRequest(params(&method, p)?),
1352 "mcp/open" => CoreRequest::McpOpen(params(&method, p)?),
1353 _ => return Err(undecoded(&method)),
1354 };
1355 Ok(CoreMessage::Request { id, request })
1356 }
1357
1358 #[cfg(test)]
1359 impl CoreMessage {
1360 #[must_use]
1361 pub fn to_value(&self) -> Value {
1362 match self {
1363 Self::Request { id, request } => request.to_value(*id),
1364 Self::Cancel(p) => notification_value("$/cancel", to_value(p)),
1365 Self::Response { id, outcome } => response_value(*id, outcome),
1366 }
1367 }
1368 }
1369
1370 #[must_use]
1371 pub fn cancel_value(id: u64) -> Value {
1372 notification_value("$/cancel", json!({ "id": id }))
1373 }
1374
1375 #[cfg(test)]
1376 mod tests;
1377
1377 lines RUST