返回 CodeWhale
tests.rs
1 //! The TypeScript half of the protocol, generated from the Rust types.
2 //!
3 //! `schemars` reads each wire type's serde shape (field names, `Option` and
4 //! `default` fields, `deny_unknown_fields`, tags); [`render`] normalizes that
5 //! into the small vocabulary the host's validator checks and renders
6 //! `extension-host/src/protocol.generated.ts`. The committed file must equal
7 //! the rendering: re-record it with `CODEWHALE_CONFORMANCE_UPDATE=1` (refused
8 //! under CI, like every conformance golden), then rebuild `dist/`.
9 //!
10 //! Known limit: a wire shape outside that vocabulary (floats, inline
11 //! objects, unions in params) panics here rather than being approximated.
12
13 use std::collections::{BTreeMap, BTreeSet};
14 use std::fmt::Write as _;
15 use std::path::Path;
16
17 use schemars::generate::SchemaSettings;
18 use schemars::{Schema, SchemaGenerator};
19
20 // `Value` and every wire type come from the parent module.
21 use super::*;
22
23 /// The params type of every method in [`METHODS`].
24 fn params_schema(method: &str, generator: &mut SchemaGenerator) -> Schema {
25 match method {
26 "host/initialize" => generator.subschema_for::<InitializeParams>(),
27 "host/ping" | "host/shutdown" | "host/ready" => generator.subschema_for::<EmptyParams>(),
28 "ext/activate" => generator.subschema_for::<ActivateParams>(),
29 "ext/deactivate" => generator.subschema_for::<DeactivateParams>(),
30 "tool/call" => generator.subschema_for::<ToolCallParams>(),
31 "command/run" => generator.subschema_for::<CommandRunParams>(),
32 "hook/evaluate" => generator.subschema_for::<HookEvaluateParams>(),
33 "harness/run" => generator.subschema_for::<HarnessRunParams>(),
34 "exec/redeem" => generator.subschema_for::<ExecutionRedeemParams>(),
35 "mcp/open" => generator.subschema_for::<McpOpenParams>(),
36 "mcp/request" => generator.subschema_for::<McpRequestParams>(),
37 "mcp/close" => generator.subschema_for::<McpCloseParams>(),
38 "proc/launch" => generator.subschema_for::<ProcLaunchParams>(),
39 "proc/read" => generator.subschema_for::<ProcSessionParams>(),
40 "proc/write" => generator.subschema_for::<ProcWriteParams>(),
41 "proc/close" => generator.subschema_for::<ProcSessionParams>(),
42 "net/start" => generator.subschema_for::<ProcLaunchParams>(),
43 "net/fetch" => generator.subschema_for::<NetFetchParams>(),
44 "net/read" | "net/release" => generator.subschema_for::<NetReadParams>(),
45 "net/close" => generator.subschema_for::<ProcSessionParams>(),
46
47 "$/cancel" => generator.subschema_for::<CancelParams>(),
48 "host/hello" => generator.subschema_for::<HelloParams>(),
49 "registry/register" => generator.subschema_for::<RegisterParams>(),
50 "registry/unregister" => generator.subschema_for::<UnregisterParams>(),
51 "core/call" => generator.subschema_for::<CoreCallParams>(),
52 "ext/faulted" => generator.subschema_for::<FaultedParams>(),
53 "log" => generator.subschema_for::<LogParams>(),
54 other => panic!("method `{other}` has no params type in the TypeScript generator"),
55 }
56 }
57
58 /// A field's wire type, normalized.
59 enum Ty {
60 String,
61 Boolean,
62 Uint,
63 Integer,
64 /// A JSON object with any keys (`Map<String, Value>`).
65 Object,
66 /// Any JSON value.
67 Json,
68 Ref(String),
69 /// A string tag inside a union member.
70 Const(String),
71 Array(Box<Ty>),
72 }
73
74 struct Field {
75 name: String,
76 ty: Ty,
77 required: bool,
78 }
79
80 enum Def {
81 Object { strict: bool, fields: Vec<Field> },
82 Enum(Vec<String>),
83 Union(Vec<Vec<Field>>),
84 }
85
86 fn ref_name(schema: &Value) -> Option<String> {
87 let reference = schema.get("$ref")?.as_str()?;
88 Some(
89 reference
90 .strip_prefix("#/$defs/")
91 .unwrap_or_else(|| panic!("unexpected $ref `{reference}`"))
92 .to_string(),
93 )
94 }
95
96 fn parse_ty(at: &str, schema: &Value) -> Ty {
97 let Some(object) = schema.as_object() else {
98 assert_eq!(schema, &Value::Bool(true), "{at}: unsupported schema");
99 return Ty::Json;
100 };
101 if let Some(name) = ref_name(schema) {
102 return Ty::Ref(name);
103 }
104 // Optional object fields use anyOf(ref, null), while scalar options use a
105 // nullable type array below. Normalize both to the present field's type:
106 // Rust omits None and the host requires a valid value when a field exists.
107 if let Some(variants) = object.get("anyOf").and_then(Value::as_array)
108 && variants.len() == 2
109 && variants
110 .iter()
111 .filter(|variant| variant.get("type").and_then(Value::as_str) == Some("null"))
112 .count()
113 == 1
114 {
115 return parse_ty(
116 at,
117 variants
118 .iter()
119 .find(|variant| variant.get("type").and_then(Value::as_str) != Some("null"))
120 .expect("one non-null variant"),
121 );
122 }
123 if let Some(value) = object.get("const").and_then(Value::as_str) {
124 return Ty::Const(value.to_string());
125 }
126 let types: Vec<&str> = match object.get("type") {
127 Some(Value::String(ty)) => vec![ty.as_str()],
128 Some(Value::Array(types)) => types
129 .iter()
130 .filter_map(Value::as_str)
131 .filter(|ty| *ty != "null")
132 .collect(),
133 // Only annotations (`default`, `description`): any value.
134 None if !["enum", "anyOf", "oneOf", "allOf", "properties", "items"]
135 .iter()
136 .any(|key| object.contains_key(*key)) =>
137 {
138 return Ty::Json;
139 }
140 _ => panic!("{at}: unsupported wire schema {schema}"),
141 };
142 match types.as_slice() {
143 ["string"] => Ty::String,
144 ["boolean"] => Ty::Boolean,
145 ["integer"] if object.get("minimum").and_then(Value::as_u64) == Some(0) => Ty::Uint,
146 ["integer"] => Ty::Integer,
147 ["object"]
148 if object.get("additionalProperties") == Some(&Value::Bool(true))
149 && !object.contains_key("properties") =>
150 {
151 Ty::Object
152 }
153 ["array"] => Ty::Array(Box::new(parse_ty(
154 &format!("{at}[]"),
155 object
156 .get("items")
157 .unwrap_or_else(|| panic!("{at}: array without items")),
158 ))),
159 _ => panic!("{at}: unsupported wire schema {schema}"),
160 }
161 }
162
163 fn object_fields(at: &str, schema: &Value) -> Vec<Field> {
164 assert_eq!(
165 schema.get("type").and_then(Value::as_str),
166 Some("object"),
167 "{at}: expected an object schema, got {schema}"
168 );
169 let required: Vec<&str> = schema
170 .get("required")
171 .and_then(Value::as_array)
172 .map(|names| names.iter().filter_map(Value::as_str).collect())
173 .unwrap_or_default();
174 schema
175 .get("properties")
176 .and_then(Value::as_object)
177 .map(|properties| {
178 properties
179 .iter()
180 .map(|(name, schema)| Field {
181 name: name.clone(),
182 ty: parse_ty(&format!("{at}.{name}"), schema),
183 required: required.contains(&name.as_str()),
184 })
185 .collect()
186 })
187 .unwrap_or_default()
188 }
189
190 fn parse_def(name: &str, schema: &Value) -> Def {
191 let members = schema
192 .get("oneOf")
193 .or_else(|| schema.get("anyOf"))
194 .and_then(Value::as_array);
195 if let Some(members) = members {
196 // Unit variants, with or without doc comments: a string enum.
197 let consts: Option<Vec<String>> = members
198 .iter()
199 .map(|member| {
200 member
201 .get("const")
202 .and_then(Value::as_str)
203 .map(str::to_string)
204 })
205 .collect();
206 return match consts {
207 Some(values) => Def::Enum(values),
208 None => Def::Union(
209 members
210 .iter()
211 .map(|member| object_fields(name, member))
212 .collect(),
213 ),
214 };
215 }
216 if let Some(values) = schema.get("enum").and_then(Value::as_array) {
217 return Def::Enum(
218 values
219 .iter()
220 .map(|value| {
221 value
222 .as_str()
223 .unwrap_or_else(|| panic!("{name}: non-string enum value"))
224 .to_string()
225 })
226 .collect(),
227 );
228 }
229 Def::Object {
230 strict: schema.get("additionalProperties") == Some(&Value::Bool(false)),
231 fields: object_fields(name, schema),
232 }
233 }
234
235 fn quote(value: &str) -> String {
236 assert!(
237 !value.contains(['\'', '\\']),
238 "`{value}` needs escaping in TypeScript"
239 );
240 format!("'{value}'")
241 }
242
243 /// The validator's kind for `ty`: a string enum is inlined, an object is a
244 /// reference into `SHAPES`.
245 fn kind(ty: &Ty, defs: &BTreeMap<String, Def>) -> String {
246 match ty {
247 Ty::String => "'string'".into(),
248 Ty::Boolean => "'boolean'".into(),
249 Ty::Uint => "'uint'".into(),
250 Ty::Integer => "'integer'".into(),
251 Ty::Object => "'object'".into(),
252 Ty::Json => "'json'".into(),
253 Ty::Ref(name) => match &defs[name] {
254 Def::Object { .. } => format!("{{ ref: {} }}", quote(name)),
255 Def::Enum(values) => format!(
256 "{{ enum: [{}] }}",
257 values
258 .iter()
259 .map(|v| quote(v))
260 .collect::<Vec<_>>()
261 .join(", ")
262 ),
263 Def::Union(_) => panic!("{name}: the host validator does not check unions"),
264 },
265 Ty::Const(value) => panic!("`{value}`: a tag outside a union"),
266 Ty::Array(item) => format!("{{ items: {} }}", kind(item, defs)),
267 }
268 }
269
270 /// The kinds of the `fields` that are (or are not) `required`.
271 fn kinds(fields: &[Field], required: bool, defs: &BTreeMap<String, Def>) -> String {
272 let rendered: Vec<String> = fields
273 .iter()
274 .filter(|field| field.required == required)
275 .map(|field| format!("{}: {}", field.name, kind(&field.ty, defs)))
276 .collect();
277 if rendered.is_empty() {
278 "{}".into()
279 } else {
280 format!("{{ {} }}", rendered.join(", "))
281 }
282 }
283
284 fn ts(ty: &Ty) -> String {
285 match ty {
286 Ty::String => "string".into(),
287 Ty::Boolean => "boolean".into(),
288 Ty::Uint | Ty::Integer => "number".into(),
289 Ty::Object => "{ [key: string]: Json }".into(),
290 Ty::Json => "Json".into(),
291 Ty::Ref(name) => name.clone(),
292 Ty::Const(value) => quote(value),
293 Ty::Array(item) => format!("{}[]", ts(item)),
294 }
295 }
296
297 /// An array's item type, however deeply nested.
298 fn innermost(ty: &Ty) -> &Ty {
299 match ty {
300 Ty::Array(item) => innermost(item),
301 ty => ty,
302 }
303 }
304
305 fn member(field: &Field) -> String {
306 let optional = if field.required { "" } else { "?" };
307 format!("{}{optional}: {}", field.name, ts(&field.ty))
308 }
309
310 const HEADER: &str = "\
311 // @generated from the Rust protocol types in crates/tui/src/extension_host/protocol.rs
312 // by `extension_host::protocol::tests`. Do not edit: change the Rust side, re-record with
313 // CODEWHALE_CONFORMANCE_UPDATE=1 cargo test -p codewhale-tui --lib extension_host::protocol
314 // and rebuild dist/ with `npm run build`.
315
316 ";
317
318 const VALIDATOR_TYPES: &str = "
319 /** A field's wire kind: the Rust field's serde type, normalized for validation. */
320 export type Kind =
321 | 'string'
322 | 'boolean'
323 | 'uint'
324 | 'integer'
325 | 'object'
326 | 'json'
327 | { readonly ref: string }
328 | { readonly enum: readonly string[] }
329 | { readonly items: Kind }
330
331 /** An object's fields; `strict` is Rust's `deny_unknown_fields`. */
332 export interface Shape {
333 readonly strict: boolean
334 readonly required: { readonly [field: string]: Kind }
335 readonly optional: { readonly [field: string]: Kind }
336 }
337 ";
338
339 /// Render `protocol.generated.ts` from [`METHODS`] and the wire types.
340 fn render() -> String {
341 let mut generator = SchemaSettings::draft2020_12().into_generator();
342 let methods: Vec<(&MethodSpec, String)> = METHODS
343 .iter()
344 .map(|spec| {
345 let schema = params_schema(spec.name, &mut generator);
346 let name = ref_name(schema.as_value())
347 .unwrap_or_else(|| panic!("{}: params must be a named type", spec.name));
348 (spec, name)
349 })
350 .collect();
351 let error = generator.subschema_for::<RpcErrorWire>();
352 let error = ref_name(error.as_value()).expect("RpcErrorWire is a named type");
353 // Results are typed for the host, not validated by it.
354 let _ = generator.subschema_for::<RegisterResult>();
355 let _ = generator.subschema_for::<ActivateResult>();
356 let _ = generator.subschema_for::<DeactivateResult>();
357 let _ = generator.subschema_for::<ToolResultWire>();
358 let _ = generator.subschema_for::<CommandResultWire>();
359 let _ = generator.subschema_for::<HookVerdictWire>();
360 let defs: BTreeMap<String, Def> = generator
361 .definitions()
362 .iter()
363 .map(|(name, schema)| (name.clone(), parse_def(name, schema)))
364 .collect();
365
366 // What the validator checks: every params type and the error object,
367 // with the objects they reference.
368 let mut validated = BTreeSet::new();
369 let mut pending: Vec<String> = methods
370 .iter()
371 .map(|(_, name)| name.clone())
372 .chain([error])
373 .collect();
374 while let Some(name) = pending.pop() {
375 if let Def::Object { fields, .. } = &defs[&name]
376 && validated.insert(name.clone())
377 {
378 for field in fields {
379 if let Ty::Ref(name) = innermost(&field.ty) {
380 pending.push(name.clone());
381 }
382 }
383 }
384 }
385
386 let mut out = String::from(HEADER);
387 let magic = std::str::from_utf8(&MAGIC).expect("ASCII magic");
388 let _ = writeln!(out, "export const PROTOCOL_VERSION = {PROTOCOL_VERSION}");
389 let _ = writeln!(out, "export const MAGIC_ASCII = {}", quote(magic));
390 let _ = writeln!(out, "export const HEADER_LEN = {HEADER_LEN}");
391 let _ = writeln!(out, "export const MAX_FRAME = {MAX_FRAME}");
392 let _ = writeln!(out, "export const MAX_INFLIGHT = {MAX_INFLIGHT}");
393 out.push_str(
394 "\n/** JSON-RPC error codes used on this channel. */\nexport const ErrorCode = {\n",
395 );
396 for (name, code) in error_code::ALL {
397 let _ = writeln!(out, " {name}: {code},");
398 }
399 out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
400 out.push_str("\n/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */\nexport const METHODS = [\n");
401 for (spec, params) in &methods {
402 let tiers: Vec<String> = spec.tiers.iter().map(|tier| quote(tier.name())).collect();
403 let _ = writeln!(
404 out,
405 " {{ name: {}, direction: {}, request: {}, params: {}, tiers: [{}] }},",
406 quote(spec.name),
407 quote(spec.direction.as_str()),
408 spec.request,
409 quote(params),
410 tiers.join(", ")
411 );
412 }
413 out.push_str("] as const\n");
414 out.push_str(VALIDATOR_TYPES);
415 out.push_str("\n/** Every method's params, and an error response's `error`. */\nexport const SHAPES: { readonly [name: string]: Shape } = {\n");
416 for name in &validated {
417 let Def::Object { strict, fields } = &defs[name] else {
418 unreachable!("only objects are validated");
419 };
420 let _ = writeln!(out, " {name}: {{");
421 let _ = writeln!(out, " strict: {strict},");
422 let _ = writeln!(out, " required: {},", kinds(fields, true, &defs));
423 let _ = writeln!(out, " optional: {},", kinds(fields, false, &defs));
424 out.push_str(" },\n");
425 }
426 out.push_str(
427 "}\n\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json }\n",
428 );
429 for (name, def) in &defs {
430 out.push('\n');
431 match def {
432 Def::Object { fields, .. } if fields.is_empty() => {
433 let _ = writeln!(out, "export interface {name} {{}}");
434 }
435 Def::Object { fields, .. } => {
436 let _ = writeln!(out, "export interface {name} {{");
437 for field in fields {
438 let _ = writeln!(out, " {}", member(field));
439 }
440 out.push_str("}\n");
441 }
442 Def::Enum(values) => {
443 let values: Vec<String> = values.iter().map(|v| quote(v)).collect();
444 let _ = writeln!(out, "export type {name} = {}", values.join(" | "));
445 }
446 Def::Union(members) => {
447 let members: Vec<String> = members
448 .iter()
449 .map(|fields| {
450 // Tags first, then the variant's fields in order.
451 let (tags, rest): (Vec<&Field>, Vec<&Field>) = fields
452 .iter()
453 .partition(|field| matches!(field.ty, Ty::Const(_)));
454 let parts: Vec<String> = tags.into_iter().chain(rest).map(member).collect();
455 format!("{{ {} }}", parts.join("; "))
456 })
457 .collect();
458 let _ = writeln!(out, "export type {name} = {}", members.join(" | "));
459 }
460 }
461 }
462 out
463 }
464
465 #[test]
466 fn typescript_protocol_is_generated_from_the_rust_types() {
467 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
468 .join("extension-host")
469 .join("src")
470 .join("protocol.generated.ts");
471 if let Err(drift) = crate::conformance::golden::check_golden(&path, &render()) {
472 panic!(
473 "{drift}\nThe TypeScript protocol is generated from protocol.rs; rebuild dist/ after re-recording."
474 );
475 }
476 }
477
478 /// Authority only the core may hold (CURRENT_DECISIONS §26): the event
479 /// authority, the store, approval, secrets and credentials, the turn loop,
480 /// sessions and the prompt. A method whose name mentions any of these is
481 /// refused outright, whatever its reviewed reason.
482 const CORE_ONLY: &[&str] = &[
483 "event",
484 "store",
485 "approv",
486 "secret",
487 "credential",
488 "token",
489 "auth",
490 "turn",
491 "loop",
492 "session",
493 "prompt",
494 ];
495
496 /// Every method, with why it gives the host no core authority. Adding a
497 /// method means adding its row here, in review, with that reason.
498 const REVIEWED: &[(&str, &str, &str)] = &[
499 (
500 "core_to_host",
501 "harness/run",
502 "pinned Builtin orchestration of an opaque exact Rust-gated job; no launch, environment, approval or session writer",
503 ),
504 (
505 "host_to_core",
506 "exec/redeem",
507 "Builtin-only host:harness; one single-use Execution grant for a Rust-held caller and prepared launch, current owner/generation/selection checks and bounded process cleanup",
508 ),
509 (
510 "host_to_core",
511 "net/start",
512 "builtin only; opaque Rust HTTP session selectors and exact decoded operation tickets, shared OAuth/egress authority, bounded revocable response reads, no credential exposure",
513 ),
514 (
515 "host_to_core",
516 "net/fetch",
517 "builtin only; opaque Rust HTTP session selectors and exact decoded operation tickets, shared OAuth/egress authority, bounded revocable response reads, no credential exposure",
518 ),
519 (
520 "host_to_core",
521 "net/read",
522 "builtin only; opaque Rust HTTP session selectors and exact decoded operation tickets, shared OAuth/egress authority, bounded revocable response reads, no credential exposure",
523 ),
524 (
525 "host_to_core",
526 "net/release",
527 "builtin only; opaque Rust HTTP session selectors and exact decoded operation tickets, shared OAuth/egress authority, bounded revocable response reads, no credential exposure",
528 ),
529 (
530 "host_to_core",
531 "net/close",
532 "builtin only; opaque Rust HTTP session selectors and exact decoded operation tickets, shared OAuth/egress authority, bounded revocable response reads, no credential exposure",
533 ),
534 (
535 "core_to_host",
536 "mcp/open",
537 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
538 ),
539 (
540 "core_to_host",
541 "mcp/request",
542 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
543 ),
544 (
545 "core_to_host",
546 "mcp/close",
547 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
548 ),
549 (
550 "host_to_core",
551 "proc/launch",
552 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
553 ),
554 (
555 "host_to_core",
556 "proc/read",
557 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
558 ),
559 (
560 "host_to_core",
561 "proc/write",
562 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
563 ),
564 (
565 "host_to_core",
566 "proc/close",
567 "builtin only; Rust mints exact owner/host-generation operation tickets, owns spawn and validates the decoded frame before a pipe write; the SDK only executes the admitted protocol exchange",
568 ),
569 (
570 "core_to_host",
571 "host/initialize",
572 "the core states its limits; the host answers `{}`",
573 ),
574 (
575 "core_to_host",
576 "host/ping",
577 "heartbeat; the host answers `{}`",
578 ),
579 (
580 "core_to_host",
581 "host/shutdown",
582 "bounded teardown, sent by tests only",
583 ),
584 (
585 "core_to_host",
586 "ext/activate",
587 "the core names the reviewed entry and its hash; the host reports tool names",
588 ),
589 (
590 "core_to_host",
591 "ext/deactivate",
592 "sent after the core has already revoked the owner",
593 ),
594 (
595 "core_to_host",
596 "tool/call",
597 "sent only after the core's approval gate has passed the call",
598 ),
599 (
600 "core_to_host",
601 "command/run",
602 "sent only when the user runs the command themselves; the answer is text or a prompt that the core shows or submits through the ordinary turn",
603 ),
604 (
605 "core_to_host",
606 "$/cancel",
607 "the core withdraws its own request",
608 ),
609 (
610 "core_to_host",
611 "hook/evaluate",
612 "the core evaluates a reviewed owner's listener; monotonic proposals are folded and any input revision is re-gated in Rust, with no approval or tool handle exposed",
613 ),
614 (
615 "host_to_core",
616 "host/hello",
617 "handshake facts the core checks against what it launched",
618 ),
619 (
620 "host_to_core",
621 "host/ready",
622 "handshake completion; no payload",
623 ),
624 (
625 "host_to_core",
626 "registry/register",
627 "a proposal the core admits or refuses; an admitted tool always needs approval, and an admitted command only runs when the user invokes it",
628 ),
629 (
630 "host_to_core",
631 "registry/unregister",
632 "the host can only withdraw its own owner's registration",
633 ),
634 (
635 "host_to_core",
636 "core/call",
637 "a request the core serves only for a ticket it minted for a call that already passed its gate, then plans and approves through the same gate as a model's call; the host names a tool and an input, never an approval, a card text, an argv, a URL or a ticket's contents",
638 ),
639 (
640 "host_to_core",
641 "ext/faulted",
642 "a report; the core revokes the owner",
643 ),
644 (
645 "host_to_core",
646 "log",
647 "diagnostic text the core bounds and escapes",
648 ),
649 (
650 "host_to_core",
651 "$/cancel",
652 "the host withdraws its own in-flight request; the core cancels that request's task and drops whatever it produces",
653 ),
654 ];
655
656 /// The CI lint for the host protocol. It covers both directions, host→core
657 /// requests included: [`METHODS`] is every method either parser admits, and
658 /// the TypeScript validator admits only its generated copy, so the host can
659 /// neither send nor answer anything outside it.
660 #[test]
661 fn host_protocol_never_gains_core_authority() {
662 for spec in METHODS {
663 let name = spec.name.to_ascii_lowercase();
664 for word in CORE_ONLY {
665 assert!(
666 !name.contains(word),
667 "{} method `{}` reaches for core-only authority (`{word}`); \
668 the extension host must never hold it (CURRENT_DECISIONS §26)",
669 spec.direction.as_str(),
670 spec.name
671 );
672 }
673 }
674 let table: BTreeSet<(&str, &str)> = METHODS
675 .iter()
676 .map(|spec| (spec.direction.as_str(), spec.name))
677 .collect();
678 let reviewed: BTreeSet<(&str, &str)> = REVIEWED
679 .iter()
680 .map(|(direction, name, _)| (*direction, *name))
681 .collect();
682 assert_eq!(
683 table, reviewed,
684 "the method table changed: review each method in REVIEWED with why it gives the host no core authority"
685 );
686 // Nothing is decoded or sent outside the table: every method-shaped
687 // literal in this module is a table row.
688 let literal = regex::Regex::new(r#""([A-Za-z$][\w$]*/[A-Za-z_]+)""#).expect("regex");
689 let source = include_str!("../protocol.rs");
690 let mut seen = 0;
691 for capture in literal.captures_iter(source) {
692 let name = &capture[1];
693 assert!(
694 METHODS.iter().any(|spec| spec.name == name),
695 "protocol.rs uses method `{name}` outside METHODS"
696 );
697 seen += 1;
698 }
699 assert!(
700 seen >= METHODS.len(),
701 "the source scan matched only {seen} literals"
702 );
703 }
704
705 /// The tier rule, against a table with methods reserved for the built-in tier
706 /// (the production table has none yet): refused to a plugin-tier host in both
707 /// directions, by the parser and by the sender's check, and open methods stay
708 /// open to both.
709 #[test]
710 fn a_method_reserved_for_the_builtin_tier_is_refused_in_both_directions() {
711 const RESERVED: &[MethodSpec] = &[
712 MethodSpec {
713 tiers: &[HostTier::Builtin],
714 ..row(Direction::HostToCore, "test/reserved", true)
715 },
716 MethodSpec {
717 tiers: &[HostTier::Builtin],
718 ..row(Direction::CoreToHost, "test/reserved-in", false)
719 },
720 row(Direction::HostToCore, "test/shared", true),
721 ];
722 use Direction::{CoreToHost, HostToCore};
723 use HostTier::{Builtin, Plugin};
724
725 assert_eq!(
726 admit_in(RESERVED, HostToCore, "test/reserved", Some(1), Builtin),
727 Ok(Some(1))
728 );
729 assert_eq!(
730 admit_in(RESERVED, CoreToHost, "test/reserved-in", None, Builtin),
731 Ok(None)
732 );
733 for (direction, method, id) in [
734 (HostToCore, "test/reserved", Some(1)),
735 (CoreToHost, "test/reserved-in", None),
736 ] {
737 let refused = admit_in(RESERVED, direction, method, id, Plugin).unwrap_err();
738 assert!(
739 refused.0.contains("not allowed on the plugin tier"),
740 "{refused}"
741 );
742 assert!(allowed_in(RESERVED, direction, method, Builtin));
743 assert!(!allowed_in(RESERVED, direction, method, Plugin));
744 }
745 // Open to both tiers, and a name or direction the table lacks is unknown
746 // (not "reserved"), whatever the tier.
747 for tier in HostTier::ALL {
748 assert_eq!(
749 admit_in(RESERVED, HostToCore, "test/shared", Some(2), tier),
750 Ok(Some(2))
751 );
752 assert!(allowed_in(RESERVED, HostToCore, "test/shared", tier));
753 for (direction, method) in [(HostToCore, "test/none"), (CoreToHost, "test/reserved")] {
754 assert!(
755 admit_in(RESERVED, direction, method, Some(3), tier)
756 .unwrap_err()
757 .0
758 .contains("unknown"),
759 "{method}"
760 );
761 assert!(!allowed_in(RESERVED, direction, method, tier));
762 }
763 }
764 }
765
766 /// The production table: `allowed_on` says what each row says, and the
767 /// families the design reserves for the built-in tier (the process broker, the
768 /// fetch proxy and the MCP client; none exist yet) can never be added to the
769 /// plugin tier by accident.
770 #[test]
771 fn production_methods_follow_their_tier_rows_and_reserved_families_stay_builtin_only() {
772 for spec in METHODS {
773 for tier in HostTier::ALL {
774 assert_eq!(
775 allowed_on(spec.direction, spec.name, tier),
776 spec.tiers.contains(&tier),
777 "{}",
778 spec.name
779 );
780 }
781 assert!(!spec.tiers.is_empty(), "{} allows no tier", spec.name);
782 if ["proc/", "net/", "mcp/"]
783 .iter()
784 .any(|family| spec.name.starts_with(family))
785 {
786 assert_eq!(
787 spec.tiers,
788 &[HostTier::Builtin],
789 "{} is reserved for the built-in tier",
790 spec.name
791 );
792 }
793 }
794 assert!(!allowed_on(
795 Direction::HostToCore,
796 "no/such-method",
797 HostTier::Builtin
798 ));
799 }
800
800 lines RUST