| 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 |