From d1d475e1798d246a058a1d8c97ebff6a58699505 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Wed, 7 Oct 2026 23:49:45 +0800 Subject: [PATCH] feat(plugin-host): Enforce plugin compatibility Resolve package formats and SDK ranges before installation or loading, and carry the selected adapter explicitly across the plugin protocol. Keep the bundled DSH payload identified by its snapshot rather than claiming unknown upstream versions. Preserve bounded legacy behavior only for attributable old installs. Record legacy marketplace mods' first-seen digests as observations, not installation evidence, and refuse later drift. Keep new mod contracts in installer records without rewriting upstream manifests. Run package compatibility and integrity tests in the macOS CI gate. Ref: E3 --- .github/workflows/release.yml | 2 + Cargo.lock | 2 + THIRD_PARTY_NOTICES.txt | 5 + .../rebon-harness/tests/plugin_plane_e2e.rs | 24 +- .../src/kernel_loop_plane.rs | 4 + .../rebon-plugin-host/src/kernel_node_host.rs | 4 + crates/rebon-plugin-host/src/mods/compose.rs | 166 ++- crates/rebon-plugin-host/src/mods/mod.rs | 28 +- .../src/plugin_composition.rs | 39 +- .../rebon-plugin-host/src/plugin_manifests.rs | 35 +- crates/rebon-plugin-host/src/plugin_plane.rs | 48 +- .../tests/kernel_seats_plane.rs | 4 + .../rebon-plugin-host/tests/mods_container.rs | 2 +- crates/rebon-plugin-package/Cargo.toml | 2 + crates/rebon-plugin-package/src/claude_mod.rs | 63 + .../rebon-plugin-package/src/compatibility.rs | 1129 +++++++++++++++++ crates/rebon-plugin-package/src/discovery.rs | 124 +- crates/rebon-plugin-package/src/integrity.rs | 177 +++ crates/rebon-plugin-package/src/lib.rs | 3 + crates/rebon-plugin-package/src/manifest.rs | 2 + .../rebon-plugin-package/src/marketplace.rs | 31 + crates/rebon-plugin-package/src/npm_range.rs | 281 ++++ .../tests/fixtures/npm-range.json | 72 ++ crates/rebon-plugin-protocol/src/lib.rs | 4 +- crates/rebon-plugin-protocol/src/methods.rs | 45 +- crates/rebon-plugin-protocol/tests/corpus.rs | 4 +- .../tests/fixtures/v1/manifest.json | 232 ++-- .../tests/fixtures/v1/methods.json | 47 + .../tests/no_arbitrary_precision.rs | 2 +- .../rebon-plugin-protocol/tests/protocol.rs | 16 +- .../rebon-plugin-protocol/tests/registry.rs | 4 + crates/rebon-plugin-supervisor/src/state.rs | 4 + .../tests/await_drain.rs | 4 + .../tests/host_process.rs | 2 +- .../tests/plugin_lifecycle.rs | 36 + .../tests/unload_reload.rs | 4 + .../src/commands/plugin.rs | 4 +- .../src/plugin/dsh_npm.rs | 173 ++- .../src/plugin/installer.rs | 137 +- .../src/plugin/marketplace.rs | 200 ++- .../src/plugin/runtime.rs | 2 + .../src/plugin/source.rs | 3 +- .../dsh-llm-deepseek/rebon-plugin.json | 7 + .../plugins/dsh-tool-todo/rebon-plugin.json | 7 + .../plugins/dsh-tool-web/rebon-plugin.json | 7 + .../dsh-web-search-exa/rebon-plugin.json | 7 + runtimes/node/compose-runtime/src/loader.mjs | 60 +- runtimes/node/compose-runtime/src/probe.mjs | 2 +- .../compose-runtime/test/composition.test.mjs | 104 ++ .../node/compose-runtime/test/harness.mjs | 6 +- runtimes/node/mods-runtime/src/index.mjs | 24 +- .../node/mods-runtime/test/client.test.mjs | 1 + .../client-mod/.claude-plugin/plugin.json | 2 +- .../counter-mod/.claude-plugin/plugin.json | 4 + .../imports-mod/.claude-plugin/plugin.json | 2 +- runtimes/node/mods-runtime/test/mod.test.mjs | 14 +- .../node/mods-runtime/test/resolve.test.mjs | 2 + runtimes/node/plugin-host/src/host.mjs | 2 + runtimes/node/plugin-host/src/json.mjs | 8 +- runtimes/node/plugin-host/src/loader.mjs | 18 +- runtimes/node/plugin-host/src/methods.mjs | 12 +- runtimes/node/plugin-host/src/protocol.mjs | 2 +- .../node/plugin-host/test/answers.test.mjs | 6 +- .../node/plugin-host/test/corpus.test.mjs | 2 +- .../node/plugin-host/test/events.test.mjs | 8 +- .../node/plugin-host/test/framing.test.mjs | 4 +- runtimes/node/plugin-host/test/host.test.mjs | 33 +- .../node/plugin-host/test/loader.test.mjs | 13 +- .../node/plugin-host/test/methods.test.mjs | 4 +- .../node/plugin-host/test/process.test.mjs | 10 +- .../node/plugin-host/test/protocol.test.mjs | 6 +- .../node/plugin-host/test/publish.test.mjs | 8 +- .../node/plugin-host/test/retire.test.mjs | 8 +- .../plugin-host/test/scope-handle.test.mjs | 6 +- .../node/plugin-host/test/streams.test.mjs | 8 +- runtimes/node/plugin-host/test/tools.test.mjs | 6 +- .../deepseek-responses/rebon-plugin.json | 6 + .../examples/hello-command/rebon-plugin.json | 6 + .../rebon-black-hole/rebon-plugin.json | 6 + .../plugins/rebon-browser/rebon-plugin.json | 6 + 80 files changed, 3273 insertions(+), 334 deletions(-) create mode 100644 crates/rebon-plugin-package/src/compatibility.rs create mode 100644 crates/rebon-plugin-package/src/integrity.rs create mode 100644 crates/rebon-plugin-package/src/npm_range.rs create mode 100644 crates/rebon-plugin-package/tests/fixtures/npm-range.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2156502..c61456a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,8 @@ jobs: run: | python3 -m py_compile scripts/build_npm_package.py scripts/publish_npm_package.py bash -n scripts/npm_publish_dist.sh + - name: Test plugin package compatibility and integrity + run: cargo test -p rebon-plugin-package --locked --no-fail-fast - name: Cargo metadata run: cargo metadata --format-version 1 --no-deps - name: Cargo check diff --git a/Cargo.lock b/Cargo.lock index 60d7b5d..f847254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,8 +3866,10 @@ dependencies = [ "rebon-hooks", "rebon-session", "rebon-types", + "semver", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "tracing", ] diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt index 61e4d9d..65f2226 100644 --- a/THIRD_PARTY_NOTICES.txt +++ b/THIRD_PARTY_NOTICES.txt @@ -130,6 +130,11 @@ deepseek-harness vendored plugins (https://github.com/deepseek-ai/deepseek-harne Vendored builds under runtimes/node/compose-runtime/payload/vendor/dsh/, each with its license file alongside as LICENSE-. Licensed under the MIT License. Transpiled 1:1 from the repository's TypeScript sources. + The upstream commit was not recorded; upstream package versions are unknown. + The vendored content is identified by snapshot S1 (cordis adapter revision 1): + sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711 + The snapshot hash covers relative file names and contents under vendor/dsh/, + with CRLF normalized to LF. timer (timer.js), logger-console (logger-console.js) Copyright (c) 2021-present Shigma timeout, llm-deepseek, the dsh-llm helper core (llm-core), scope, diff --git a/crates/rebon-harness/tests/plugin_plane_e2e.rs b/crates/rebon-harness/tests/plugin_plane_e2e.rs index 91364f8..f834abc 100644 --- a/crates/rebon-harness/tests/plugin_plane_e2e.rs +++ b/crates/rebon-harness/tests/plugin_plane_e2e.rs @@ -225,6 +225,10 @@ fn payload_entry(id: &str, module: &str, config: Value) -> ComposeEntry { id: id.to_owned(), root: plain(&repo().join("runtimes/node/compose-runtime/payload")), entry: module.to_owned(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "cordis".into(), + revision: 1, + }, config, ..ComposeEntry::default() } @@ -542,7 +546,7 @@ async fn the_marketplaces_dsh_packages_load_and_register_their_tools() { return; }; let package = repo().join("marketplace/plugins").join(id); - let (declared_as, manifest) = read_package_manifest(&package.join(module)) + let (declared_as, manifest, compatibility) = read_package_manifest(&package.join(module)) .expect("the package declares a kernel plugin"); assert_eq!(declared_as, id); let raw: Value = serde_json::from_slice( @@ -562,6 +566,10 @@ async fn the_marketplaces_dsh_packages_load_and_register_their_tools() { manifest.entry.clone().expect("an entry module"), config, &["WebSearch".to_string(), "WebFetch".to_string()], + rebon_plugin_protocol::PluginAdapter { + id: compatibility.adapter_id().into(), + revision: rebon_plugin_package::compatibility::ADAPTER_REVISION, + }, ); fixture .plane @@ -1283,11 +1291,15 @@ async fn the_example_package_registers_its_command() { }; let module = repo().join("runtimes/node/plugins/examples/hello-command/plugin.mjs"); - let (declared_as, manifest) = + let (declared_as, manifest, compatibility) = read_package_manifest(&module).expect("the example package declares a kernel plugin"); assert_eq!(declared_as, "hello-command"); let entry = ComposeEntry { id: declared_as, + adapter: rebon_plugin_protocol::PluginAdapter { + id: compatibility.adapter_id().into(), + revision: rebon_plugin_package::compatibility::ADAPTER_REVISION, + }, root: plain(module.parent().expect("the package directory")), entry: manifest .entry @@ -1426,6 +1438,10 @@ fn probe_entry(id: &str, tool: &str, container: Option) -> Compos id: id.into(), root: plain(&root), entry: "probe.mjs".into(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "cordis".into(), + revision: 1, + }, config: serde_json::json!({ "tool": tool }), tools: vec![tool.into()], container, @@ -1705,6 +1721,10 @@ async fn a_dsh_plugin_s_commands_are_rebon_commands_that_answer_in_place() { id: "dsh-commands".into(), root: plain(&package), entry: "commands.mjs".into(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "cordis".into(), + revision: 1, + }, commands: vec!["greet".into(), "refuse".into()], container: Some(ContainerSpec { id: "commands-box".into(), diff --git a/crates/rebon-plugin-host/src/kernel_loop_plane.rs b/crates/rebon-plugin-host/src/kernel_loop_plane.rs index 87e869c..064f11b 100644 --- a/crates/rebon-plugin-host/src/kernel_loop_plane.rs +++ b/crates/rebon-plugin-host/src/kernel_loop_plane.rs @@ -362,6 +362,10 @@ fn loop_entries( module, config, exposed_tools, + rebon_plugin_protocol::PluginAdapter { + id: "cordis".into(), + revision: 1, + }, )); } Ok(out) diff --git a/crates/rebon-plugin-host/src/kernel_node_host.rs b/crates/rebon-plugin-host/src/kernel_node_host.rs index c559e35..31d1a24 100644 --- a/crates/rebon-plugin-host/src/kernel_node_host.rs +++ b/crates/rebon-plugin-host/src/kernel_node_host.rs @@ -164,6 +164,10 @@ pub async fn bind_plane_model_provider( id: contribution.id.clone(), root: crate::plugin_manifests::plain_path(&transport.root), entry: transport.entry.clone(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, // The user's connection settings ride each turn rather than the load, // because the same loaded adapter serves whichever provider entry is // selected right now. See `ModelProviderTurnV1`. diff --git a/crates/rebon-plugin-host/src/mods/compose.rs b/crates/rebon-plugin-host/src/mods/compose.rs index 954bcb4..e74ae00 100644 --- a/crates/rebon-plugin-host/src/mods/compose.rs +++ b/crates/rebon-plugin-host/src/mods/compose.rs @@ -6,8 +6,8 @@ //! `.claude-plugin/plugin.json`), and `plugins` lists the name; //! * the folder sits under `/mods/`, or a folder `REBON_MOD_DIRS` //! or `CLAUDE_CODE_PLUGIN_DIRS` names — loaded under the manifest's own -//! name, with no configuration written, which is what makes dropping a -//! mod folder there enough; +//! name, with no configuration written, provided its manifest or install +//! record declares a supported compatibility contract; //! * `rebon plugin install ` recorded it, with the manifest this //! crate synthesised beside it, and an installed package is found by name //! like any other. @@ -83,7 +83,7 @@ pub fn mod_entry( all_tools: &[String], ) -> Result { let mod_ = read_claude_mod(root)?; - Ok(entry_for_mod(id, &mod_, config_dir, extra, all_tools)) + entry_for_mod(id, &mod_, config_dir, extra, all_tools) } pub fn entry_for_mod( @@ -92,7 +92,9 @@ pub fn entry_for_mod( config_dir: &Path, extra: &Value, all_tools: &[String], -) -> ComposeEntry { +) -> Result { + let compatibility = rebon_plugin_package::compatibility::resolve_mod(mod_, config_dir) + .map_err(|error| error.to_string())?; let manifest = kernel_manifest_for(mod_); let mut stored = stored_options(config_dir, &mod_.manifest.name).unwrap_or(Value::Null); if let (Some(into), Some(from)) = (stored.as_object_mut(), extra.as_object()) { @@ -106,14 +108,18 @@ pub fn entry_for_mod( for warning in &mod_.scan.warnings { tracing::warn!(mod_ = %mod_.manifest.name, %warning, "mod scan"); } - entry_for( + Ok(entry_for( &manifest, id, mod_.root.clone(), mod_.hooks_module.clone(), mod_config(mod_, options), all_tools, - ) + rebon_plugin_protocol::PluginAdapter { + id: compatibility.adapter_id().into(), + revision: rebon_plugin_package::compatibility::ADAPTER_REVISION, + }, + )) } /// Every mod folder this machine loads without being configured for it. @@ -151,14 +157,13 @@ pub fn discovered_entries( )); continue; } - names.push(name.clone()); - out.push(entry_for_mod( - &name, - &mod_, - config_dir, - &Value::Null, - all_tools, - )); + match entry_for_mod(&name, &mod_, config_dir, &Value::Null, all_tools) { + Ok(entry) => { + names.push(name); + out.push(entry); + } + Err(reason) => skipped.push(format!("{}: {reason}", plain_path(&root))), + } } out } @@ -179,7 +184,7 @@ mod tests { std::fs::create_dir_all(root.join("hooks")).unwrap(); std::fs::write( root.join(CLAUDE_PLUGIN_MANIFEST), - json!({ "name": name, "version": "0.1.0", "userConfig": { "prefix": { "type": "string", "default": "n=" } } }).to_string(), + json!({ "name": name, "version": "0.1.0", "rebon": {"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]}, "userConfig": { "prefix": { "type": "string", "default": "n=" } } }).to_string(), ) .unwrap(); std::fs::write( @@ -212,6 +217,135 @@ mod tests { assert_eq!(mod_root_of(dir.path()), None); } + #[test] + fn a_mod_directory_needs_a_supported_contract_before_it_becomes_an_entry() { + for (case, message) in [ + ("missing", "missing compatibility"), + ("format", "unsupported plugin format"), + ("sdk", "unsupported SDK"), + ("adapter", "unsupported adapter"), + ] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("mod"); + write_mod(&root, "demo"); + let path = root.join(CLAUDE_PLUGIN_MANIFEST); + let mut manifest: Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + match case { + "missing" => { + manifest.as_object_mut().unwrap().remove("rebon"); + } + "format" => manifest["rebon"]["formatVersion"] = json!(2), + "sdk" => manifest["rebon"]["sdk"][0]["range"] = json!("^2"), + "adapter" => manifest["rebon"]["adapterRevision"] = json!(2), + _ => unreachable!(), + } + std::fs::write(&path, manifest.to_string()).unwrap(); + let error = mod_entry("demo", &root, &dir.path().join("config"), &Value::Null, &[]) + .unwrap_err(); + assert!(error.contains(message), "{error}"); + } + } + + #[test] + fn a_marketplace_mod_uses_only_its_recorded_contract_and_unchanged_content() { + use rebon_plugin_package::marketplace::{ + InstallKind, MarketplaceInstall, MarketplaceInstalls, ModCompatibilityRecord, + }; + for (case, message) in [ + ("current", None), + ("changed", Some("no longer match")), + ("legacy", None), + ("provenance", Some("provenance")), + ("identity", Some("runtime identity")), + ("elsewhere", Some("no installer record")), + ("future", Some("unsupported adapter")), + ("format", Some("unsupported plugin format")), + ("sdk", Some("unsupported SDK")), + ] { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + let root = config.join("mods/demo"); + write_mod(&root, "demo"); + let path = root.join(CLAUDE_PLUGIN_MANIFEST); + let mut raw: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + raw.as_object_mut().unwrap().remove("rebon"); + std::fs::write(&path, raw.to_string()).unwrap(); + let mut mod_ = read_claude_mod(&root).unwrap(); + mod_.declare_install_compatibility(); + let mut install = MarketplaceInstall { + marketplace: "team".to_owned(), + plugin: "demo".to_owned(), + kind: InstallKind::Mod, + location: root.clone(), + version: None, + kernel_plugins: vec!["demo".to_owned()], + installed_at_ms: 1, + granted: Default::default(), + source_identity: Some( + rebon_plugin_package::store::PluginSourceIdentity::Marketplace { + catalog: rebon_plugin_package::marketplace::MarketplaceSource::Directory { + path: dir.path().join("catalog"), + }, + plugin: "demo".to_owned(), + source: rebon_plugin_package::marketplace::PluginSource::Relative( + "./mods/demo".to_owned(), + ), + }, + ), + mod_compatibility: Some(ModCompatibilityRecord { + manifest: rebon_plugin_package::plugin_manifest_for(&mod_), + digest: rebon_plugin_package::integrity::compute_dir_digest(&root).unwrap(), + first_seen: false, + }), + }; + match case { + "changed" => std::fs::write(root.join("extra.js"), "changed").unwrap(), + "legacy" => install.mod_compatibility = None, + "provenance" => install.source_identity = None, + "identity" => install.kernel_plugins.clear(), + "elsewhere" => install.location = dir.path().to_path_buf(), + "future" | "format" | "sdk" => { + let declaration = install + .mod_compatibility + .as_mut() + .unwrap() + .manifest + .compatibility + .as_mut() + .unwrap(); + match case { + "future" => declaration.adapter_revision = 99, + "format" => declaration.format = "rebon-plugin".to_owned(), + "sdk" => declaration.sdk[0].range = "^2".to_owned(), + _ => unreachable!(), + } + } + "current" => {} + _ => unreachable!(), + } + let mut installs = MarketplaceInstalls::default(); + installs.by_id.insert("demo@team".to_owned(), install); + installs.save(&config).unwrap(); + let result = mod_entry("demo", &root, &config, &Value::Null, &[]); + if let Some(message) = message { + let error = result.unwrap_err(); + assert!(error.contains(message), "{case}: {error}"); + } else { + let entry = result.unwrap(); + assert_eq!( + entry.adapter.id, + if case == "legacy" { + "legacy-1.9" + } else { + "claude-mods" + } + ); + assert_eq!(entry.adapter.revision, 1); + } + } + } + #[test] fn a_mod_entry_carries_the_ceiling_the_marker_and_the_stored_options() { let dir = tempfile::tempdir().unwrap(); @@ -233,6 +367,8 @@ mod tests { ) .unwrap(); assert_eq!(entry.id, "tally"); + assert_eq!(entry.adapter.id, "claude-mods"); + assert_eq!(entry.adapter.revision, 1); assert_eq!(entry.entry, "hooks/register.ts"); assert_eq!(entry.services, vec!["mod"]); assert_eq!(entry.commands, vec!["hi"]); diff --git a/crates/rebon-plugin-host/src/mods/mod.rs b/crates/rebon-plugin-host/src/mods/mod.rs index 76e0089..162ffc6 100644 --- a/crates/rebon-plugin-host/src/mods/mod.rs +++ b/crates/rebon-plugin-host/src/mods/mod.rs @@ -92,6 +92,9 @@ pub struct ModRecord { impl ModRecord { fn from_entry(entry: &ComposeEntry) -> Option { + if !ModsRegistry::entry_is_mod(entry) { + return None; + } let marker = entry.config.get(MOD_MARKER)?.as_object()?; let name = marker .get("name") @@ -464,7 +467,14 @@ impl ModsRegistry { /// Whether this entry would be a mod, before it is attached. pub fn entry_is_mod(entry: &ComposeEntry) -> bool { - rebon_plugin_package::is_mod_config(&entry.config) + entry.adapter.revision == 1 + && match entry.adapter.id.as_str() { + "claude-mods" => true, + // Only the explicit legacy profile retains marker-based selection. + // Remove this branch with legacy-1.9 in the next major. + "legacy-1.9" => rebon_plugin_package::is_mod_config(&entry.config), + _ => false, + } } /// Records a loaded mod and puts its commands and tools on the seats. @@ -1109,6 +1119,10 @@ mod tests { id: "tally".into(), root: "/mods/tally".into(), entry: "hooks/register.ts".into(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "claude-mods".into(), + revision: 1, + }, config: json!({ MOD_MARKER: { "name": "tally", "version": "1.0.0", "events": ["tool.call", "classic.*"], "calls": ["ui.status"], @@ -1126,5 +1140,17 @@ mod tests { assert_eq!(record.tools()[0].name, "mcp__tally__count"); assert_eq!(record.facts().cwd, "/mods/tally"); assert!(ModRecord::from_entry(&ComposeEntry::default()).is_none()); + for id in ["native", "cordis", "unknown"] { + let mut other = entry.clone(); + other.adapter.id = id.into(); + assert!(!ModsRegistry::entry_is_mod(&other)); + assert!(ModRecord::from_entry(&other).is_none()); + } + let mut legacy = entry.clone(); + legacy.adapter.id = "legacy-1.9".into(); + assert!(ModsRegistry::entry_is_mod(&legacy)); + assert!(ModRecord::from_entry(&legacy).is_some()); + legacy.adapter.revision = 2; + assert!(!ModsRegistry::entry_is_mod(&legacy)); } } diff --git a/crates/rebon-plugin-host/src/plugin_composition.rs b/crates/rebon-plugin-host/src/plugin_composition.rs index 0b95d10..3cd12c6 100644 --- a/crates/rebon-plugin-host/src/plugin_composition.rs +++ b/crates/rebon-plugin-host/src/plugin_composition.rs @@ -345,7 +345,18 @@ fn load_request( } else { roots.payload.clone() }; - return Ok(entry_for(manifest, id, root, entry, config, all_tools)); + return Ok(entry_for( + manifest, + id, + root, + entry, + config, + all_tools, + rebon_plugin_protocol::PluginAdapter { + id: "cordis".into(), + revision: 1, + }, + )); } let Some(module) = modules.get(name) else { // Nothing pointed at a file, so the name has to have been declared by @@ -379,7 +390,7 @@ fn load_request( // A package that declares nothing is not loaded declaring nothing: it would // start, be refused at every registration it attempts, and look like a bug // in the plugin rather than a missing manifest. - let (declared_as, manifest) = read_package_manifest(module) + let (declared_as, manifest, compatibility) = read_package_manifest(module) .map_err(|problem| format!("{} — {problem}", plain_path(&root)))?; tracing::debug!( plugin = id, @@ -387,7 +398,18 @@ fn load_request( package = %root.display(), "composition entry loaded against its package's own declaration" ); - Ok(entry_for(&manifest, id, root, file, config, all_tools)) + Ok(entry_for( + &manifest, + id, + root, + file, + config, + all_tools, + rebon_plugin_protocol::PluginAdapter { + id: compatibility.adapter_id().into(), + revision: rebon_plugin_package::compatibility::ADAPTER_REVISION, + }, + )) } /// The load request for a kernel plugin an installed package declares, @@ -408,6 +430,10 @@ pub(crate) fn installed_entry( module, config, all_tools, + rebon_plugin_protocol::PluginAdapter { + id: declared.compatibility.adapter_id().into(), + revision: rebon_plugin_package::compatibility::ADAPTER_REVISION, + }, ); entry.source = declared.source.clone(); Some(entry) @@ -450,6 +476,8 @@ mod tests { let entry = &out.entries[0]; assert_eq!(entry.id, "llm"); assert_eq!(entry.entry, "vendor/dsh/llm-deepseek.js"); + assert_eq!(entry.adapter.id, "cordis"); + assert_eq!(entry.adapter.revision, 1); assert_eq!(entry.llm_providers, vec!["deepseek-official"]); assert_eq!(entry.seats, vec!["credentials"]); assert_eq!(entry.config, serde_json::json!({ "a": 1 })); @@ -466,6 +494,9 @@ mod tests { ..KernelPluginManifest::default() }, source: Some(format!("source-of-{name}")), + compatibility: rebon_plugin_package::compatibility::LoadCompatibility::Current( + rebon_plugin_package::compatibility::PluginFormat::Rebon, + ), } } @@ -504,7 +535,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("rebon-plugin.json"), - br#"{"name":"local","version":"0.0.1","capabilities":{"kernelPlugins":{ + br#"{"name":"local","version":"0.0.1","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]},"capabilities":{"kernelPlugins":{ "demo-plane": { "entry": "local.mjs", "services": ["from-disk"] }}}}"#, ) .unwrap(); diff --git a/crates/rebon-plugin-host/src/plugin_manifests.rs b/crates/rebon-plugin-host/src/plugin_manifests.rs index 245629c..84c7114 100644 --- a/crates/rebon-plugin-host/src/plugin_manifests.rs +++ b/crates/rebon-plugin-host/src/plugin_manifests.rs @@ -40,8 +40,8 @@ use crate::plugin_plane::ComposeEntry; /// answer waiting to drift. pub use rebon_plugin_package::PLUGIN_MANIFEST_FILE; -/// Turns a manifest plus one composition entry's configuration into the load -/// request rebon will send. +/// Turns a manifest plus one composition entry's configuration and explicitly +/// selected adapter into the load request rebon will send. pub fn entry_for( manifest: &KernelPluginManifest, id: &str, @@ -49,11 +49,13 @@ pub fn entry_for( entry: String, config: Value, all_tools: &[String], + adapter: rebon_plugin_protocol::PluginAdapter, ) -> ComposeEntry { ComposeEntry { id: id.to_owned(), root: plain_path(&root), entry, + adapter, config, services: manifest.services.clone(), event_topics: manifest.event_topics.clone(), @@ -188,7 +190,14 @@ impl std::fmt::Display for ManifestProblem { /// have been giving all along. pub fn read_package_manifest( module: &Path, -) -> Result<(String, KernelPluginManifest), ManifestProblem> { +) -> Result< + ( + String, + KernelPluginManifest, + rebon_plugin_package::compatibility::LoadCompatibility, + ), + ManifestProblem, +> { let dir = module.parent().ok_or(ManifestProblem::Missing)?; let raw = std::fs::read(dir.join(PLUGIN_MANIFEST_FILE)).map_err(|_| ManifestProblem::Missing)?; @@ -220,7 +229,9 @@ pub fn read_package_manifest( manifest .validate_for_package(name) .map_err(ManifestProblem::Refused)?; - Ok((name.clone(), manifest.clone())) + let compatibility = rebon_plugin_package::compatibility::resolve_package(dir, &package, None) + .map_err(|error| ManifestProblem::Refused(error.to_string()))?; + Ok((name.clone(), manifest.clone(), compatibility)) } /// Whether a declaration's `entry` names this module file. @@ -244,6 +255,16 @@ mod tests { use super::*; fn package(dir: &Path, body: &str) { + let body = match serde_json::from_str::(body) { + Ok(mut value) => { + value["compatibility"] = serde_json::json!({ + "format":"rebon-plugin","formatVersion":1,"adapterRevision":1, + "sdk":[{"name":"rebon-plugin-api","range":"^1"}] + }); + value.to_string() + } + Err(_) => body.to_owned(), + }; std::fs::write(dir.join(PLUGIN_MANIFEST_FILE), body).unwrap(); } @@ -270,7 +291,7 @@ mod tests { }"#, ); - let (name, manifest) = read_package_manifest(&dir.path().join("index.mjs")).unwrap(); + let (name, manifest, _) = read_package_manifest(&dir.path().join("index.mjs")).unwrap(); assert_eq!(name, "demo-plane"); assert_eq!(manifest.services, vec!["echo".to_string()]); assert_eq!( @@ -344,9 +365,9 @@ mod tests { "first": { "entry": "one.mjs", "services": ["a"] }, "second": { "entry": "./nested/two.mjs", "services": ["b"] }}}}"#, ); - let (first, _) = read_package_manifest(&dir.path().join("one.mjs")).unwrap(); + let (first, _, _) = read_package_manifest(&dir.path().join("one.mjs")).unwrap(); assert_eq!(first, "first"); - let (second, manifest) = read_package_manifest(&dir.path().join("two.mjs")).unwrap(); + let (second, manifest, _) = read_package_manifest(&dir.path().join("two.mjs")).unwrap(); assert_eq!(second, "second"); assert_eq!(manifest.services, vec!["b".to_string()]); } diff --git a/crates/rebon-plugin-host/src/plugin_plane.rs b/crates/rebon-plugin-host/src/plugin_plane.rs index a94712a..5a1fc24 100644 --- a/crates/rebon-plugin-host/src/plugin_plane.rs +++ b/crates/rebon-plugin-host/src/plugin_plane.rs @@ -56,7 +56,7 @@ use rebon_kernel::{ Context, JsonService, KernelError, PluginLifecycleChanged, SharedLifecycleSink, }; use rebon_plugin_protocol::{ - CommandInvokeRequest, Payload, PluginCommandDefinition, PluginCommandKind, + CommandInvokeRequest, Payload, PluginAdapter, PluginCommandDefinition, PluginCommandKind, PluginCommandSurface, PluginLoadRequest, PluginReadyReport, RegistryError, }; use rebon_plugin_supervisor::{ @@ -102,6 +102,7 @@ pub struct ComposeEntry { pub id: String, pub root: String, pub entry: String, + pub adapter: PluginAdapter, #[serde(default)] pub config: Value, #[serde(default)] @@ -163,6 +164,10 @@ impl Default for ComposeEntry { id: String::new(), root: String::new(), entry: String::new(), + adapter: PluginAdapter { + id: "native".into(), + revision: 1, + }, config: Value::Null, services: Vec::new(), event_topics: Vec::new(), @@ -193,6 +198,7 @@ impl ComposeEntry { plugin_id: self.id.clone(), root, entry: self.entry.clone(), + adapter: self.adapter.clone(), services: self.services.clone(), event_topics: self.event_topics.clone(), published_topics: self.published_topics.clone(), @@ -699,6 +705,10 @@ impl PluginPlane { plugin_id: COMPOSE_PLUGIN_ID.to_owned(), root: config.compose_root.to_string_lossy().into_owned(), entry: "src/plugin.mjs".to_owned(), + adapter: PluginAdapter { + id: "native".into(), + revision: 1, + }, services: vec!["compose".to_owned()], config: Payload::from(config.control_config()), event_topics: Vec::new(), @@ -909,6 +919,10 @@ impl PluginPlane { plugin_id: COMPOSE_PLUGIN_ID.to_owned(), root: compose_root.to_string_lossy().into_owned(), entry: "src/plugin.mjs".to_owned(), + adapter: PluginAdapter { + id: "native".into(), + revision: 1, + }, services: vec!["compose".to_owned()], config: Payload::from(serde_json::json!({ "payloadDir": payload_dir.as_ref().map(|p| p.to_string_lossy().into_owned()), @@ -2830,6 +2844,10 @@ mod reload_tests { id: id.to_string(), root: "/packages/demo".to_string(), entry: "index.mjs".to_string(), + adapter: PluginAdapter { + id: "native".into(), + revision: 1, + }, config, services: vec!["echo".to_string()], event_topics: Vec::new(), @@ -2879,6 +2897,34 @@ mod reload_tests { assert!(plan.stop.is_empty()); } + #[test] + fn adapter_changes_restart_the_entry_and_travel_on_the_load_request() { + let original = entry("a", Value::Null); + assert_eq!(original.adapter, ComposeEntry::default().adapter); + for adapter in [ + PluginAdapter { + id: "cordis".into(), + revision: 1, + }, + PluginAdapter { + id: "native".into(), + revision: 2, + }, + ] { + let mut changed = original.clone(); + changed.adapter = adapter.clone(); + assert_eq!(changed.load_request().adapter, adapter); + let plan = classify(&running(&[original.clone()]), &[changed]); + assert_eq!(plan.changed, vec!["a".to_string()]); + assert_eq!(plan.stop, vec!["a".to_string()]); + assert_eq!(plan.start, vec![0]); + assert!(plan.unchanged.is_empty()); + } + let mut encoded = serde_json::to_value(&original).unwrap(); + encoded.as_object_mut().unwrap().remove("adapter"); + assert!(serde_json::from_value::(encoded).is_err()); + } + /// A changed entry is a stop *and* a start: an id cannot be loaded twice, /// so there is no in-place update to make. #[test] diff --git a/crates/rebon-plugin-host/tests/kernel_seats_plane.rs b/crates/rebon-plugin-host/tests/kernel_seats_plane.rs index 7482d0d..66c09f4 100644 --- a/crates/rebon-plugin-host/tests/kernel_seats_plane.rs +++ b/crates/rebon-plugin-host/tests/kernel_seats_plane.rs @@ -115,6 +115,10 @@ async fn probe( plugin_id: "seat.probe".into(), root: dir.path().to_string_lossy().replace('\\', "/"), entry: "index.mjs".into(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, services: vec!["report".into()], event_topics: Vec::new(), published_topics: Vec::new(), diff --git a/crates/rebon-plugin-host/tests/mods_container.rs b/crates/rebon-plugin-host/tests/mods_container.rs index 3534820..47db674 100644 --- a/crates/rebon-plugin-host/tests/mods_container.rs +++ b/crates/rebon-plugin-host/tests/mods_container.rs @@ -106,7 +106,7 @@ fn write_probe_mod(into: &Path) -> PathBuf { std::fs::create_dir_all(root.join("hooks")).unwrap(); std::fs::write( root.join(".claude-plugin/plugin.json"), - json!({ "name": "probe-mod", "version": "0.1.0" }).to_string(), + json!({ "name": "probe-mod", "version": "0.1.0", "rebon": {"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]} }).to_string(), ) .unwrap(); std::fs::write( diff --git a/crates/rebon-plugin-package/Cargo.toml b/crates/rebon-plugin-package/Cargo.toml index 0bbd172..70c449d 100644 --- a/crates/rebon-plugin-package/Cargo.toml +++ b/crates/rebon-plugin-package/Cargo.toml @@ -15,6 +15,8 @@ rebon-types.workspace = true # The install state file is published through the shared atomic write. rebon-session.workspace = true anyhow.workspace = true +semver = "1.0.28" +sha2.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/rebon-plugin-package/src/claude_mod.rs b/crates/rebon-plugin-package/src/claude_mod.rs index 527ec07..5e04543 100644 --- a/crates/rebon-plugin-package/src/claude_mod.rs +++ b/crates/rebon-plugin-package/src/claude_mod.rs @@ -25,6 +25,7 @@ use rebon_types::{ }; use serde_json::Value; +use crate::compatibility::{CompatibilityDeclaration, PluginFormat}; use crate::manifest::{PluginCapabilities, PluginManifest}; /// The seats every mod may call: its own `$`, its settings namespace, and @@ -37,12 +38,30 @@ pub struct ClaudeMod { /// The folder holding `.claude-plugin/plugin.json`, as given. pub root: PathBuf, pub manifest: ClaudeModManifest, + pub compatibility: Option, pub hooks: ClaudeModHooksFile, /// The hooks module, relative to the root with forward slashes. pub hooks_module: String, pub scan: ModScan, } +impl ClaudeMod { + pub fn declare_install_compatibility(&mut self) { + if self.compatibility.is_none() { + self.compatibility = Some(CompatibilityDeclaration { + format: PluginFormat::ClaudeMods.name().to_owned(), + format_version: crate::compatibility::FORMAT_VERSION, + adapter_revision: crate::compatibility::ADAPTER_REVISION, + sdk: vec![crate::compatibility::SdkRequirement { + name: "rebon-claude-mods-api".to_owned(), + range: "^1".to_owned(), + }], + dsh_snapshot: None, + }); + } + } +} + /// Whether `path` is a folder with a mod manifest in it. pub fn is_claude_mod_dir(path: &Path) -> bool { path.join(CLAUDE_PLUGIN_MANIFEST).is_file() @@ -73,6 +92,14 @@ pub fn read_claude_mod(root: &Path) -> Result { manifest.name )); } + let compatibility = manifest + .rest + .get("rebon") + .map(|value| { + CompatibilityDeclaration::read(&manifest.name, Some(value), PluginFormat::ClaudeMods) + }) + .transpose() + .map_err(|error| error.to_string())?; let hooks_path = root.join(CLAUDE_HOOKS_FILE); let raw = std::fs::read(&hooks_path).map_err(|error| { format!( @@ -110,6 +137,7 @@ pub fn read_claude_mod(root: &Path) -> Result { Ok(ClaudeMod { root: root.to_path_buf(), manifest, + compatibility, hooks, hooks_module, scan, @@ -554,6 +582,7 @@ pub fn plugin_manifest_for(mod_: &ClaudeMod) -> PluginManifest { .version .clone() .unwrap_or_else(|| "0.0.0".to_owned()), + compatibility: mod_.compatibility.clone(), description: mod_.manifest.description.clone(), source: None, capabilities: PluginCapabilities { @@ -856,6 +885,40 @@ export const register = on => { assert_eq!(package.metadata["claudeMod"], json!(true)); } + #[test] + fn only_installation_supplies_a_missing_mod_contract_and_preserves_overrides() { + for explicit in [false, true] { + let dir = tempfile::tempdir().unwrap(); + write_mod(dir.path(), MODULE); + let path = dir.path().join(CLAUDE_PLUGIN_MANIFEST); + if explicit { + let mut raw: Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + raw["rebon"] = json!({"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"=1.0.0"}]}); + std::fs::write(&path, raw.to_string()).unwrap(); + } + let before = std::fs::read(&path).unwrap(); + let mut mod_ = read_claude_mod(dir.path()).unwrap(); + assert_eq!(mod_.compatibility.is_some(), explicit); + mod_.declare_install_compatibility(); + let declared = mod_.compatibility.clone(); + mod_.declare_install_compatibility(); + assert_eq!(mod_.compatibility, declared); + let manifest = plugin_manifest_for(&mod_); + let declaration = manifest.compatibility.as_ref().unwrap(); + assert_eq!(declaration.format, "claude-mods"); + assert_eq!( + declaration.sdk[0].range, + if explicit { "=1.0.0" } else { "^1" } + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!( + read_claude_mod(dir.path()).unwrap().compatibility.is_some(), + explicit + ); + } + } + #[test] fn a_mod_that_calls_tools_reaches_every_tool() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/rebon-plugin-package/src/compatibility.rs b/crates/rebon-plugin-package/src/compatibility.rs new file mode 100644 index 0000000..bf5913d --- /dev/null +++ b/crates/rebon-plugin-package/src/compatibility.rs @@ -0,0 +1,1129 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::path::Path; + +use crate::npm_range::NpmRange; +use semver::Version; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const FORMAT_VERSION: u32 = 1; +pub const ADAPTER_REVISION: u32 = 1; +pub const DSH_SNAPSHOT: &str = + "sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompatibilityDeclaration { + pub format: String, + pub format_version: u32, + pub adapter_revision: u32, + pub sdk: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub dsh_snapshot: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SdkRequirement { + pub name: String, + pub range: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PluginFormat { + Rebon, + DshNpm, + ClaudeMods, +} + +impl PluginFormat { + pub fn name(self) -> &'static str { + match self { + Self::Rebon => "rebon-plugin", + Self::DshNpm => "dsh-npm", + Self::ClaudeMods => "claude-mods", + } + } + + pub fn adapter_id(self) -> &'static str { + match self { + Self::Rebon => "native", + Self::DshNpm => "cordis", + Self::ClaudeMods => "claude-mods", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CompatibilityError { + MissingCompatibility { + plugin: String, + reason: String, + }, + UnsupportedFormat { + plugin: String, + requested: String, + supported: String, + }, + UnsupportedSdk { + plugin: String, + name: String, + requirement: String, + provided: Option, + }, + UnsupportedAdapter { + plugin: String, + adapter: String, + requested: u32, + supported: u32, + }, +} + +impl fmt::Display for CompatibilityError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingCompatibility { plugin, reason } => write!(f, "{plugin}: missing compatibility declaration ({reason}); update the declaration or reinstall from a compatible source"), + Self::UnsupportedFormat { plugin, requested, supported } => write!(f, "{plugin}: unsupported plugin format {requested}; supported: {supported}"), + Self::UnsupportedSdk { plugin, name, requirement, provided } => { + write!(f, "{plugin}: unsupported SDK {name} requirement {requirement}; ")?; + match provided { + Some(version) => write!(f, "host provides {version}"), + None => write!(f, "host has not declared a version for this module"), + } + } + Self::UnsupportedAdapter { plugin, adapter, requested, supported } => write!(f, "{plugin}: unsupported adapter {adapter} revision {requested}; supported revision: {supported}"), + } + } +} + +impl std::error::Error for CompatibilityError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoadCompatibility { + Current(PluginFormat), + // Removed at the next major release; never selected from a package declaration. + Legacy19, +} + +impl LoadCompatibility { + pub fn adapter_id(self) -> &'static str { + match self { + Self::Current(format) => format.adapter_id(), + Self::Legacy19 => "legacy-1.9", + } + } +} + +pub fn resolve_package( + root: &Path, + manifest: &crate::manifest::PluginManifest, + record: Option<&crate::store::InstalledPluginRecord>, +) -> Result { + if let Some(declaration) = &manifest.compatibility { + let format = declaration.plugin_format(&manifest.name)?; + declaration.validate_format(&manifest.name, format)?; + declaration.validate_sdk(&manifest.name, &host_sdk_versions())?; + return Ok(LoadCompatibility::Current(format)); + } + let missing = |reason: &str| CompatibilityError::MissingCompatibility { + plugin: manifest.name.clone(), + reason: reason.to_owned(), + }; + let record = record.ok_or_else(|| missing("plugin-dir has no installer record"))?; + if record.source_identity.is_none() || record.manifest.as_ref() != Some(manifest) { + return Err(missing( + "installer provenance or the original manifest is unavailable", + )); + } + let digest = record + .digest + .as_deref() + .ok_or_else(|| missing("the installer did not record an integrity digest"))?; + let actual = crate::integrity::compute_legacy_dir_digest(root) + .map_err(|error| missing(&format!("cannot verify installed content: {error}")))?; + if actual != digest { + return Err(missing( + "installed files no longer match the installer record", + )); + } + Ok(LoadCompatibility::Legacy19) +} + +pub fn resolve_mod( + mod_: &crate::claude_mod::ClaudeMod, + config_home: &Path, +) -> Result { + let mut manifest = crate::claude_mod::plugin_manifest_for(mod_); + let missing = |reason: String| CompatibilityError::MissingCompatibility { + plugin: manifest.name.clone(), + reason, + }; + let root = mod_ + .root + .canonicalize() + .map_err(|error| missing(error.to_string()))?; + let installs = crate::marketplace::MarketplaceInstalls::load(config_home) + .map_err(|error| missing(error.to_string()))?; + let installed = installs.by_id.iter().find(|(_, record)| { + record.kind == crate::marketplace::InstallKind::Mod + && record + .location + .canonicalize() + .is_ok_and(|location| location == root) + }); + if manifest.compatibility.is_some() + && installed.is_none_or(|(_, install)| { + !install + .mod_compatibility + .as_ref() + .is_some_and(|record| record.first_seen) + }) + { + return resolve_package(&root, &manifest, None); + } + let (install_id, install) = + installed.ok_or_else(|| missing("mod directory has no installer record".to_owned()))?; + if !install.matches_mod_owner(install_id, &manifest.name) { + return Err(missing( + "installer provenance or runtime identity does not match".to_owned(), + )); + } + if !config_home + .join(rebon_types::MODS_DIR) + .join(&install.plugin) + .canonicalize() + .is_ok_and(|expected| expected == root) + { + return Err(missing( + "mod directory does not match its installed location".to_owned(), + )); + } + if install + .mod_compatibility + .as_ref() + .is_none_or(|record| record.first_seen) + { + return resolve_legacy_mod(&root, &manifest, config_home, &installs, install_id); + } + let recorded = install + .mod_compatibility + .as_ref() + .expect("current mod record exists"); + let actual = crate::integrity::compute_dir_digest(&mod_.root) + .map_err(|error| missing(format!("cannot verify installed content: {error}")))?; + if actual != recorded.digest { + return Err(missing( + "installed files no longer match the installer record".to_owned(), + )); + } + manifest.compatibility = recorded.manifest.compatibility.clone(); + if manifest != recorded.manifest { + return Err(CompatibilityError::MissingCompatibility { + plugin: manifest.name, + reason: "the synthesized manifest no longer matches the installer record".to_owned(), + }); + } + if let Some(declaration) = &manifest.compatibility { + declaration.validate_format(&manifest.name, PluginFormat::ClaudeMods)?; + } + resolve_package(&mod_.root, &manifest, None) +} + +fn resolve_legacy_mod( + root: &Path, + manifest: &crate::manifest::PluginManifest, + config_home: &Path, + installs: &crate::marketplace::MarketplaceInstalls, + install_id: &str, +) -> Result { + let missing = |reason: String| CompatibilityError::MissingCompatibility { + plugin: manifest.name.clone(), + reason, + }; + let actual = crate::integrity::compute_legacy_dir_digest(root) + .map_err(|error| missing(format!("cannot verify installed content: {error}")))?; + let install = installs + .by_id + .get(install_id) + .expect("matched mod install exists"); + let write_error = if let Some(recorded) = &install.mod_compatibility { + if !recorded.first_seen + || recorded.manifest.compatibility.is_some() + || recorded.manifest != *manifest + || recorded.digest != actual + { + return Err(missing( + "installed files no longer match the first-seen record".to_owned(), + )); + } + None + } else { + (|| -> anyhow::Result<()> { + let mut latest = crate::marketplace::MarketplaceInstalls::load(config_home)?; + let current = latest.by_id.get_mut(install_id).ok_or_else(|| { + anyhow::anyhow!("mod install was removed before recording its digest") + })?; + if current != install { + anyhow::bail!("mod install changed before recording its digest"); + } + current.mod_compatibility = Some(crate::marketplace::ModCompatibilityRecord { + manifest: manifest.clone(), + digest: actual, + first_seen: true, + }); + latest.save(config_home) + })() + .err() + }; + if let Some(mut warning) = legacy_mod_diagnostic(root, &manifest.name) { + if let Some(error) = write_error { + warning.push_str(&format!( + " Could not persist the first-seen digest: {error}; the next load will retry." + )); + } + tracing::warn!(plugin = %manifest.name, %warning, "legacy mod compatibility"); + } + Ok(LoadCompatibility::Legacy19) +} + +fn legacy_mod_diagnostic(root: &Path, plugin: &str) -> Option { + legacy_diagnostic(root, plugin).map(|warning| { + format!("{warning} Integrity at installation cannot be verified; the first-seen digest is only a later observation.") + }) +} + +pub fn legacy_diagnostic(root: &Path, plugin: &str) -> Option { + static REPORTED: std::sync::Mutex> = + std::sync::Mutex::new(BTreeSet::new()); + if !REPORTED + .lock() + .expect("legacy diagnostic registry poisoned") + .insert(root.to_path_buf()) + { + return None; + } + Some(format!("{plugin}: loading with Rebon legacy-1.9 compatibility; reinstall or update its compatibility declaration. This profile will be removed in the next major release.")) +} + +pub fn host_sdk_versions() -> BTreeMap { + BTreeMap::from([ + ("rebon-plugin-api".to_owned(), "1.0.0".to_owned()), + ("rebon-claude-mods-api".to_owned(), "1.0.0".to_owned()), + ("cordis".to_owned(), "4.0.0-rc.8".to_owned()), + ("@deepseek-ai/cordis".to_owned(), "4.0.0-rc.8".to_owned()), + ("cosmokit".to_owned(), "1.8.1".to_owned()), + ("@deepseek-ai/cosmokit".to_owned(), "1.8.1".to_owned()), + ("schemastery".to_owned(), "3.18.0".to_owned()), + ("@deepseek-ai/schemastery".to_owned(), "3.18.0".to_owned()), + ("eventsource-parser".to_owned(), "3.1.0".to_owned()), + ]) +} + +impl CompatibilityDeclaration { + pub fn plugin_format(&self, plugin: &str) -> Result { + match self.format.as_str() { + "rebon-plugin" => Ok(PluginFormat::Rebon), + "dsh-npm" => Ok(PluginFormat::DshNpm), + "claude-mods" => Ok(PluginFormat::ClaudeMods), + other => Err(CompatibilityError::UnsupportedFormat { + plugin: plugin.to_owned(), + requested: other.to_owned(), + supported: "rebon-plugin, dsh-npm, claude-mods".to_owned(), + }), + } + } + pub fn read( + plugin: &str, + value: Option<&Value>, + expected: PluginFormat, + ) -> Result { + let value = value.ok_or_else(|| CompatibilityError::MissingCompatibility { + plugin: plugin.to_owned(), + reason: format!("{} requires an explicit declaration", expected.name()), + })?; + let declaration: Self = serde_json::from_value(value.clone()).map_err(|error| { + CompatibilityError::UnsupportedFormat { + plugin: plugin.to_owned(), + requested: format!("invalid declaration: {error}"), + supported: format!("{} version {FORMAT_VERSION}", expected.name()), + } + })?; + declaration.validate_format(plugin, expected)?; + Ok(declaration) + } + + pub fn validate_format( + &self, + plugin: &str, + expected: PluginFormat, + ) -> Result<(), CompatibilityError> { + if self.format != expected.name() + || self.format_version != FORMAT_VERSION + || (self.dsh_snapshot.is_some() && expected != PluginFormat::DshNpm) + { + return Err(CompatibilityError::UnsupportedFormat { + plugin: plugin.to_owned(), + requested: format!("{} version {}", self.format, self.format_version), + supported: format!("{} version {FORMAT_VERSION}", expected.name()), + }); + } + if self.adapter_revision != ADAPTER_REVISION { + return Err(CompatibilityError::UnsupportedAdapter { + plugin: plugin.to_owned(), + adapter: expected.adapter_id().to_owned(), + requested: self.adapter_revision, + supported: ADAPTER_REVISION, + }); + } + Ok(()) + } + + /// Host-provided modules include prereleases when matching npm ranges. + /// Their exact versions still come from the host, never from package claims. + pub fn validate_sdk( + &self, + plugin: &str, + provided: &BTreeMap, + ) -> Result<(), CompatibilityError> { + let names: &[&str] = match self.plugin_format(plugin)? { + PluginFormat::Rebon => &["rebon-plugin-api"], + PluginFormat::DshNpm => &["cordis", "@deepseek-ai/cordis"], + PluginFormat::ClaudeMods => &["rebon-claude-mods-api"], + }; + if !self + .sdk + .iter() + .any(|requirement| names.contains(&requirement.name.as_str())) + { + return Err(CompatibilityError::MissingCompatibility { + plugin: plugin.to_owned(), + reason: format!("no SDK requirement for {}", names.join(" or ")), + }); + } + if let Some(snapshot) = &self.dsh_snapshot { + if snapshot != DSH_SNAPSHOT { + return Err(CompatibilityError::UnsupportedSdk { + plugin: plugin.to_owned(), + name: "rebon-dsh-snapshot".to_owned(), + requirement: snapshot.clone(), + provided: Some(DSH_SNAPSHOT.to_owned()), + }); + } + } + for requirement in &self.sdk { + let version = provided.get(&requirement.name); + let matches = version + .and_then(|version| Version::parse(version).ok()) + .zip(NpmRange::parse(&requirement.range)) + .is_some_and(|(version, range)| range.matches(&version)); + if !matches { + return Err(CompatibilityError::UnsupportedSdk { + plugin: plugin.to_owned(), + name: requirement.name.clone(), + requirement: requirement.range.clone(), + provided: version.cloned(), + }); + } + } + Ok(()) + } +} + +pub fn npm_sdk_requirements( + plugin: &str, + package: &Value, + provided: &BTreeSet, +) -> Result, CompatibilityError> { + let mut requirements = Vec::new(); + for field in ["dependencies", "peerDependencies"] { + let Some(entries) = package.get(field) else { + continue; + }; + let entries = entries + .as_object() + .ok_or_else(|| CompatibilityError::UnsupportedFormat { + plugin: plugin.to_owned(), + requested: format!("{field} is not an object"), + supported: "npm dependency objects".to_owned(), + })?; + for (name, range) in entries { + if !provided.contains(name) { + continue; + } + let range = range + .as_str() + .ok_or_else(|| CompatibilityError::UnsupportedSdk { + plugin: plugin.to_owned(), + name: name.clone(), + requirement: range.to_string(), + provided: None, + })?; + if NpmRange::parse(range).is_none() { + return Err(CompatibilityError::UnsupportedSdk { + plugin: plugin.to_owned(), + name: name.clone(), + requirement: range.to_owned(), + provided: None, + }); + } + requirements.push(SdkRequirement { + name: name.clone(), + range: range.to_owned(), + }); + } + } + Ok(requirements) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn legacy_marketplace_mod(config: &Path) -> crate::claude_mod::ClaudeMod { + let root = config.join("mods/demo"); + std::fs::create_dir_all(root.join(".claude-plugin")).unwrap(); + std::fs::create_dir_all(root.join("hooks")).unwrap(); + std::fs::write( + root.join(".claude-plugin/plugin.json"), + r#"{"name":"demo"}"#, + ) + .unwrap(); + std::fs::write( + root.join("hooks/hooks.json"), + r#"{"modules":["./register.ts"]}"#, + ) + .unwrap(); + std::fs::write( + root.join("hooks/register.ts"), + "export const register = () => {};", + ) + .unwrap(); + let installs: crate::marketplace::MarketplaceInstalls = serde_json::from_value(json!({ + "demo@team": { + "marketplace": "team", "plugin": "demo", "kind": "mod", + "location": root, "kernelPlugins": ["demo"], "installedAtMs": 1, + "sourceIdentity": { + "kind": "marketplace", "plugin": "demo", + "catalog": {"source":"directory", "path":config.join("catalog")}, + "source": "./mods/demo" + } + } + })) + .unwrap(); + installs.save(config).unwrap(); + crate::claude_mod::read_claude_mod(&root).unwrap() + } + + #[test] + fn legacy_marketplace_mod_records_a_first_seen_digest_not_install_evidence() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + assert_eq!( + resolve_mod(&mod_, dir.path()).unwrap(), + LoadCompatibility::Legacy19 + ); + let raw: Value = serde_json::from_slice( + &std::fs::read(crate::marketplace::MarketplaceInstalls::path(dir.path())).unwrap(), + ) + .unwrap(); + let record = &raw["demo@team"]["modCompatibility"]; + assert_eq!(record["firstSeen"], true); + assert_eq!( + record["digest"], + crate::integrity::compute_legacy_dir_digest(&mod_.root).unwrap() + ); + assert!(record["manifest"]["compatibility"].is_null()); + } + + #[test] + fn legacy_marketplace_mod_unchanged_content_keeps_its_first_seen_record() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + resolve_mod(&mod_, dir.path()).unwrap(); + let path = crate::marketplace::MarketplaceInstalls::path(dir.path()); + let before = std::fs::read(&path).unwrap(); + assert_eq!( + resolve_mod(&mod_, dir.path()).unwrap(), + LoadCompatibility::Legacy19 + ); + assert_eq!(std::fs::read(path).unwrap(), before); + } + + #[test] + fn legacy_marketplace_mod_changed_content_cannot_replace_its_first_seen_digest() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + resolve_mod(&mod_, dir.path()).unwrap(); + let path = crate::marketplace::MarketplaceInstalls::path(dir.path()); + let before = std::fs::read(&path).unwrap(); + std::fs::write(mod_.root.join("extra.js"), "changed").unwrap(); + let error = resolve_mod(&mod_, dir.path()).unwrap_err(); + assert!(matches!( + error, + CompatibilityError::MissingCompatibility { .. } + )); + assert!(error.to_string().contains("first-seen")); + assert_eq!(std::fs::read(path).unwrap(), before); + } + + #[test] + fn legacy_marketplace_mod_cannot_bypass_first_seen_by_adding_a_declaration() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + resolve_mod(&mod_, dir.path()).unwrap(); + std::fs::write( + mod_.root.join(".claude-plugin/plugin.json"), + json!({ + "name":"demo", "rebon": { + "format":"claude-mods", "formatVersion":1, "adapterRevision":1, + "sdk":[{"name":"rebon-claude-mods-api", "range":"^1"}] + } + }) + .to_string(), + ) + .unwrap(); + let mod_ = crate::claude_mod::read_claude_mod(&mod_.root).unwrap(); + assert!(matches!( + resolve_mod(&mod_, dir.path()), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + } + + #[test] + fn legacy_marketplace_mod_refuses_mismatched_ownership_before_recording_content() { + for case in [ + "source", + "plugin", + "runtime", + "extra-runtime", + "location", + "key", + ] { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + let mut installs = crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(); + let record = installs.by_id.get_mut("demo@team").unwrap(); + match case { + "source" => record.source_identity = None, + "plugin" => record.plugin = "other".to_owned(), + "runtime" => record.kernel_plugins = vec!["other".to_owned()], + "extra-runtime" => record.kernel_plugins.push("other".to_owned()), + "location" => record.location = dir.path().to_path_buf(), + "key" => { + let record = installs.by_id.remove("demo@team").unwrap(); + installs.by_id.insert("other@team".to_owned(), record); + } + _ => unreachable!(), + } + installs.save(dir.path()).unwrap(); + assert!( + matches!( + resolve_mod(&mod_, dir.path()), + Err(CompatibilityError::MissingCompatibility { .. }) + ), + "{case}" + ); + assert_eq!( + crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(), + installs + ); + } + } + + #[test] + fn legacy_marketplace_mod_preserves_installs_added_before_digest_save() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + let snapshot = crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(); + let mut latest = snapshot.clone(); + let mut other = latest.by_id["demo@team"].clone(); + other.plugin = "other".to_owned(); + latest.by_id.insert("other@team".to_owned(), other.clone()); + latest.save(dir.path()).unwrap(); + let manifest = crate::claude_mod::plugin_manifest_for(&mod_); + assert_eq!( + resolve_legacy_mod(&mod_.root, &manifest, dir.path(), &snapshot, "demo@team").unwrap(), + LoadCompatibility::Legacy19 + ); + let saved = crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(); + assert_eq!(saved.by_id.get("other@team"), Some(&other)); + assert!( + saved.by_id["demo@team"] + .mod_compatibility + .as_ref() + .unwrap() + .first_seen + ); + } + + #[test] + fn legacy_marketplace_mod_does_not_overwrite_a_changed_install_before_digest_save() { + for case in [ + "removed", + "source", + "location", + "runtime", + "reinstalled", + "recorded", + ] { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + let manifest = crate::claude_mod::plugin_manifest_for(&mod_); + let snapshot = crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(); + let mut latest = snapshot.clone(); + let install = latest.by_id.get_mut("demo@team").unwrap(); + match case { + "removed" => { + latest.by_id.remove("demo@team"); + } + "source" => install.source_identity = None, + "location" => install.location = dir.path().join("elsewhere"), + "runtime" => install.kernel_plugins = vec!["other".to_owned()], + "reinstalled" => install.installed_at_ms += 1, + "recorded" => { + install.mod_compatibility = Some(crate::marketplace::ModCompatibilityRecord { + manifest: manifest.clone(), + digest: "another observation".to_owned(), + first_seen: true, + }) + } + _ => unreachable!(), + } + latest.save(dir.path()).unwrap(); + resolve_legacy_mod(&mod_.root, &manifest, dir.path(), &snapshot, "demo@team").unwrap(); + assert_eq!( + crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(), + latest, + "{case}" + ); + } + } + + #[test] + fn legacy_marketplace_mod_keeps_loading_when_first_seen_cannot_be_saved() { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + let installs = crate::marketplace::MarketplaceInstalls::load(dir.path()).unwrap(); + let blocked = dir.path().join("blocked"); + std::fs::create_dir_all(crate::marketplace::MarketplaceInstalls::path(&blocked)).unwrap(); + let manifest = crate::claude_mod::plugin_manifest_for(&mod_); + assert_eq!( + resolve_legacy_mod(&mod_.root, &manifest, &blocked, &installs, "demo@team").unwrap(), + LoadCompatibility::Legacy19 + ); + assert!(crate::marketplace::MarketplaceInstalls::load(dir.path()) + .unwrap() + .by_id["demo@team"] + .mod_compatibility + .is_none()); + assert_eq!( + resolve_mod(&mod_, dir.path()).unwrap(), + LoadCompatibility::Legacy19 + ); + assert!( + crate::marketplace::MarketplaceInstalls::load(dir.path()) + .unwrap() + .by_id["demo@team"] + .mod_compatibility + .as_ref() + .unwrap() + .first_seen + ); + } + + #[test] + fn legacy_marketplace_mod_diagnostic_explains_integrity_and_expiry_once() { + let dir = tempfile::tempdir().unwrap(); + let warning = legacy_mod_diagnostic(dir.path(), "demo").unwrap(); + for expected in [ + "legacy-1.9", + "reinstall", + "next major release", + "cannot be verified", + "first-seen", + ] { + assert!(warning.contains(expected), "{warning}"); + } + assert!(legacy_mod_diagnostic(dir.path(), "demo").is_none()); + } + + #[cfg(unix)] + #[test] + fn legacy_marketplace_mod_only_accepts_internal_npm_bin_links() { + for internal in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let mod_ = legacy_marketplace_mod(dir.path()); + let bin = mod_.root.join("node_modules/.bin"); + std::fs::create_dir_all(&bin).unwrap(); + let target = if internal { + mod_.root.join("node_modules/cli.js") + } else { + dir.path().join("outside.js") + }; + std::fs::write(&target, "cli").unwrap(); + std::os::unix::fs::symlink(&target, bin.join("cli")).unwrap(); + if internal { + assert_eq!( + resolve_mod(&mod_, dir.path()).unwrap(), + LoadCompatibility::Legacy19 + ); + std::fs::write(target, "changed").unwrap(); + } + assert!(matches!( + resolve_mod(&mod_, dir.path()), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + } + } + + fn declaration() -> Value { + json!({"format":"dsh-npm", "formatVersion":1, "adapterRevision":1, "sdk":[{"name":"@deepseek-ai/cordis","range":"^4"}]}) + } + + fn snapshot_digest(root: &Path) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + for (relative, path) in crate::integrity::package_files(root).unwrap() { + hasher.update(relative.as_bytes()); + hasher.update([0]); + hasher.update( + std::fs::read_to_string(path) + .unwrap() + .replace("\r\n", "\n") + .as_bytes(), + ); + hasher.update([0]); + } + format!("sha256:{:x}", hasher.finalize()) + } + + #[test] + fn the_bundled_snapshot_and_first_party_declarations_cannot_drift() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_eq!( + snapshot_digest(&root.join("runtimes/node/compose-runtime/payload/vendor/dsh")), + DSH_SNAPSHOT + ); + for name in [ + "dsh-llm-deepseek", + "dsh-tool-todo", + "dsh-tool-web", + "dsh-web-search-exa", + ] { + let manifest = crate::manifest::PluginManifest::load_from_dir( + &root.join("marketplace/plugins").join(name), + ) + .unwrap(); + let declaration = manifest + .compatibility + .as_ref() + .expect("first-party snapshot declaration"); + declaration + .validate_format(name, PluginFormat::DshNpm) + .unwrap(); + assert_eq!(declaration.dsh_snapshot.as_deref(), Some(DSH_SNAPSHOT)); + declaration + .validate_sdk(name, &host_sdk_versions()) + .unwrap(); + } + } + + #[test] + fn snapshot_identity_tracks_names_and_content_but_not_checkout_line_endings() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("a.js"); + std::fs::write(&file, "first\nsecond\n").unwrap(); + let original = snapshot_digest(root.path()); + std::fs::write(&file, "first\r\nsecond\r\n").unwrap(); + assert_eq!(snapshot_digest(root.path()), original); + std::fs::write(&file, "changed\nsecond\n").unwrap(); + let changed = snapshot_digest(root.path()); + assert_ne!(changed, original); + std::fs::rename(&file, root.path().join("b.js")).unwrap(); + assert_ne!(snapshot_digest(root.path()), changed); + } + + #[test] + fn a_snapshot_mismatch_names_both_identities_and_does_not_bypass_npm_peers() { + let mut value = declaration(); + value["dshSnapshot"] = json!("sha256:old"); + let mut parsed = + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm).unwrap(); + let error = parsed + .validate_sdk("demo", &host_sdk_versions()) + .unwrap_err(); + assert!(error.to_string().contains("sha256:old")); + assert!(error.to_string().contains(DSH_SNAPSHOT)); + parsed.dsh_snapshot = Some(DSH_SNAPSHOT.to_owned()); + parsed.validate_sdk("demo", &host_sdk_versions()).unwrap(); + parsed.sdk.push(SdkRequirement { + name: "@deepseek-ai/dsh-session".to_owned(), + range: "*".to_owned(), + }); + assert!(matches!( + parsed.validate_sdk("demo", &host_sdk_versions()), + Err(CompatibilityError::UnsupportedSdk { provided: None, .. }) + )); + } + + #[test] + fn documented_vendor_versions_cover_the_resolver_aliases() { + let mut value = declaration(); + value["sdk"].as_array_mut().unwrap().extend([ + json!({"name":"cosmokit","range":"^1.8"}), + json!({"name":"@deepseek-ai/cosmokit","range":"1.8.1"}), + json!({"name":"schemastery","range":"^3"}), + json!({"name":"@deepseek-ai/schemastery","range":"3.18.0"}), + json!({"name":"eventsource-parser","range":"^3.1"}), + ]); + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm) + .unwrap() + .validate_sdk("demo", &host_sdk_versions()) + .unwrap(); + } + + #[test] + fn reads_an_explicit_contract_without_using_package_version() { + let value = declaration(); + let parsed = + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm).unwrap(); + assert_eq!(parsed.format_version, 1); + assert_eq!(serde_json::to_value(parsed).unwrap(), value); + } + + #[test] + fn missing_and_malformed_declarations_are_not_current_contracts() { + assert!(matches!( + CompatibilityDeclaration::read("demo", None, PluginFormat::DshNpm), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + for value in [ + Value::Null, + json!({}), + json!({"format":"dsh-npm","formatVersion":"1","adapterRevision":1,"sdk":[]}), + ] { + assert!(matches!( + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm), + Err(CompatibilityError::UnsupportedFormat { .. }) + )); + } + } + + #[test] + fn unsupported_formats_and_ecosystems_do_not_fall_back() { + for version in [0, 2, u32::MAX] { + let mut value = declaration(); + value["formatVersion"] = json!(version); + assert!(matches!( + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm), + Err(CompatibilityError::UnsupportedFormat { .. }) + )); + } + for format in ["opencode", "pi", "claude-mods", "rebon-plugin"] { + let mut value = declaration(); + value["format"] = json!(format); + assert!(matches!( + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm), + Err(CompatibilityError::UnsupportedFormat { .. }) + )); + } + } + + #[test] + fn unknown_adapter_revisions_are_distinct_errors() { + for revision in [0, 2, u32::MAX] { + let mut value = declaration(); + value["adapterRevision"] = json!(revision); + assert!(matches!( + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm), + Err(CompatibilityError::UnsupportedAdapter { .. }) + )); + } + } + + #[test] + fn host_prereleases_match_npm_ranges_without_becoming_stable_versions() { + let mut value = declaration(); + let provided = + BTreeMap::from([("@deepseek-ai/cordis".to_owned(), "4.0.0-rc.8".to_owned())]); + for (range, expected) in [ + ("^4", true), + ("4.0.0-rc.8", true), + ("^3 || ^4", true), + (">=4.0.0", false), + ("^5", false), + ("^4.1", false), + ("garbage", false), + ] { + value["sdk"][0]["range"] = json!(range); + let parsed = + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm).unwrap(); + assert_eq!( + parsed.validate_sdk("demo", &provided).is_ok(), + expected, + "{range}" + ); + } + } + + #[test] + fn an_unknown_host_sdk_is_not_satisfied_by_a_wildcard() { + let mut value = declaration(); + value["sdk"][0]["range"] = json!("*"); + let parsed = + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm).unwrap(); + let error = parsed.validate_sdk("demo", &BTreeMap::new()).unwrap_err(); + assert!(matches!( + error, + CompatibilityError::UnsupportedSdk { provided: None, .. } + )); + assert!(error + .to_string() + .contains("host has not declared a version")); + } + + #[test] + fn preserves_both_dependency_constraints_before_slimming() { + let package = json!({"version":"999", "dependencies":{"cordis":"^4", "external":"^1"}, "peerDependencies":{"cordis":">=4.0.0-rc.8"}}); + let requirements = + npm_sdk_requirements("demo", &package, &BTreeSet::from(["cordis".to_owned()])).unwrap(); + assert_eq!(requirements.len(), 2); + assert_eq!(requirements[0].range, "^4"); + assert_eq!(requirements[1].range, ">=4.0.0-rc.8"); + } + + #[test] + fn legacy_installs_need_provenance_and_unchanged_content() { + use crate::store::{InstalledPluginRecord, PluginSourceIdentity, PluginSourceKind}; + let root = tempfile::tempdir().unwrap(); + let manifest: crate::manifest::PluginManifest = + serde_json::from_value(json!({"name":"demo", "version":"1"})).unwrap(); + std::fs::write( + root.path().join("rebon-plugin.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(root.path().join("index.js"), "original").unwrap(); + let mut record = InstalledPluginRecord { + name: "demo".to_owned(), + version: "1".to_owned(), + enabled: true, + disabled_capabilities: Vec::new(), + source_kind: PluginSourceKind::Local, + source: None, + source_identity: Some(PluginSourceIdentity::local(root.path()).unwrap()), + digest: Some(crate::integrity::compute_dir_digest(root.path()).unwrap()), + manifest: Some(manifest.clone()), + }; + assert_eq!( + resolve_package(root.path(), &manifest, Some(&record)).unwrap(), + LoadCompatibility::Legacy19 + ); + std::fs::write(root.path().join("index.js"), "modified").unwrap(); + assert!(matches!( + resolve_package(root.path(), &manifest, Some(&record)), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + std::fs::write(root.path().join("index.js"), "original").unwrap(); + record.source_identity = None; + assert!(matches!( + resolve_package(root.path(), &manifest, Some(&record)), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + } + + #[cfg(any(unix, windows))] + #[test] + fn a_symlink_added_after_install_disqualifies_the_legacy_profile() { + use crate::store::{InstalledPluginRecord, PluginSourceIdentity, PluginSourceKind}; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("plugin"); + std::fs::create_dir(&root).unwrap(); + let manifest: crate::manifest::PluginManifest = + serde_json::from_value(json!({"name":"demo", "version":"1"})).unwrap(); + let record = InstalledPluginRecord { + name: "demo".to_owned(), + version: "1".to_owned(), + enabled: true, + disabled_capabilities: Vec::new(), + source_kind: PluginSourceKind::Local, + source: None, + source_identity: Some(PluginSourceIdentity::local(&root).unwrap()), + digest: Some(crate::integrity::compute_dir_digest(&root).unwrap()), + manifest: Some(manifest.clone()), + }; + let target = dir.path().join("outside.js"); + std::fs::write(&target, "outside").unwrap(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&target, root.join("extra.js")); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&target, root.join("extra.js")); + if let Err(error) = linked { + #[cfg(windows)] + if error.kind() == std::io::ErrorKind::PermissionDenied { + eprintln!( + "skipping symlink test: Windows did not grant symbolic-link creation: {error}" + ); + return; + } + panic!("creating the symlink: {error}"); + } + let error = resolve_package(&root, &manifest, Some(&record)).unwrap_err(); + assert!(matches!( + error, + CompatibilityError::MissingCompatibility { .. } + )); + assert!(error.to_string().contains("unverifiable entry"), "{error}"); + } + + #[test] + fn plugin_dirs_cannot_claim_the_legacy_profile() { + let root = tempfile::tempdir().unwrap(); + let manifest: crate::manifest::PluginManifest = + serde_json::from_value(json!({"name":"demo", "version":"1"})).unwrap(); + assert!(matches!( + resolve_package(root.path(), &manifest, None), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + let mut value = declaration(); + value["format"] = json!("legacy-1.9"); + assert!(matches!( + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm), + Err(CompatibilityError::UnsupportedFormat { .. }) + )); + } + + #[test] + fn a_declaration_must_name_the_sdk_its_adapter_exposes() { + let mut value = declaration(); + value["sdk"] = json!([]); + let parsed = + CompatibilityDeclaration::read("demo", Some(&value), PluginFormat::DshNpm).unwrap(); + assert!(matches!( + parsed.validate_sdk("demo", &host_sdk_versions()), + Err(CompatibilityError::MissingCompatibility { .. }) + )); + } + + #[test] + fn malformed_sdk_requirements_are_refused() { + let provided = BTreeSet::from(["cordis".to_owned()]); + for range in [ + json!(42), + json!("workspace:*"), + json!("https://example.com/sdk.tgz"), + ] { + let package = json!({"peerDependencies":{"cordis":range}}); + assert!(matches!( + npm_sdk_requirements("demo", &package, &provided), + Err(CompatibilityError::UnsupportedSdk { .. }) + )); + } + assert!(matches!( + npm_sdk_requirements("demo", &json!({"dependencies":[]}), &provided), + Err(CompatibilityError::UnsupportedFormat { .. }) + )); + } +} diff --git a/crates/rebon-plugin-package/src/discovery.rs b/crates/rebon-plugin-package/src/discovery.rs index 9494a39..f8ebd89 100644 --- a/crates/rebon-plugin-package/src/discovery.rs +++ b/crates/rebon-plugin-package/src/discovery.rs @@ -36,6 +36,7 @@ use std::sync::Mutex; use rebon_types::KernelPluginManifest; +use crate::compatibility::{legacy_diagnostic, resolve_package, LoadCompatibility, PluginFormat}; use crate::manifest::{PluginManifest, PLUGIN_MANIFEST_FILE}; use crate::store::{ InstalledPluginRecord, PluginScope, PluginSourceIdentity, PluginSourceKind, PluginStore, @@ -81,6 +82,7 @@ pub struct DiscoveredPlugin { /// The label already used in diagnostics and MCP config attribution. pub source: String, pub source_identity: Option, + pub compatibility: LoadCompatibility, pub plugin: InstalledPlugin, } @@ -113,6 +115,7 @@ pub struct KernelPluginDeclaration { /// [`PluginSourceIdentity::key`]. `None` when the install predates source /// tracking and its origin is unknown. pub source: Option, + pub compatibility: LoadCompatibility, } /// Everything in effect, plus what could not be read. @@ -161,6 +164,7 @@ impl Discovered { name: name.clone(), root: root.to_path_buf(), manifest: declaration.clone(), + compatibility: plugin.compatibility, source: plugin .source_identity .as_ref() @@ -294,7 +298,14 @@ pub fn discover( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(entry) = remembered.as_ref() { - if entry.asked_for == asked_for && entry.stamp == stamp { + if entry.asked_for == asked_for + && entry.stamp == stamp + && !entry + .found + .plugins + .iter() + .any(|plugin| plugin.compatibility == LoadCompatibility::Legacy19) + { return Ok(entry.found.clone()); } } @@ -337,11 +348,15 @@ fn resolve( continue; } }; - match PluginManifest::load_from_dir(&root) { - Ok(manifest) => out.plugins.push(DiscoveredPlugin { + match PluginManifest::load_from_dir(&root).and_then(|manifest| { + let compatibility = resolve_package(&root, &manifest, None)?; + Ok((manifest, compatibility)) + }) { + Ok((manifest, compatibility)) => out.plugins.push(DiscoveredPlugin { origin: PluginOrigin::SessionDir, source: format!("plugin:{}@local:session", manifest.name), source_identity: Some(source_identity), + compatibility, plugin: InstalledPlugin::Package { root, manifest: Box::new(manifest), @@ -405,6 +420,7 @@ fn push_record( origin, source, source_identity: record.source_identity.clone(), + compatibility: LoadCompatibility::Current(PluginFormat::Rebon), plugin: InstalledPlugin::Builtin { name: record.name.clone(), }, @@ -418,16 +434,28 @@ fn push_record( Some(manifest) => Ok(manifest), None => PluginManifest::load_from_dir(&root), }; - match manifest { - Ok(manifest) => out.plugins.push(DiscoveredPlugin { - origin, - source, - source_identity: record.source_identity.clone(), - plugin: InstalledPlugin::Package { - root, - manifest: Box::new(manifest), - }, - }), + let admitted = manifest.and_then(|manifest| { + let compatibility = resolve_package(&root, &manifest, Some(record))?; + Ok((manifest, compatibility)) + }); + match admitted { + Ok((manifest, compatibility)) => { + if compatibility == LoadCompatibility::Legacy19 { + if let Some(diagnostic) = legacy_diagnostic(&root, &manifest.name) { + out.warnings.push(diagnostic); + } + } + out.plugins.push(DiscoveredPlugin { + origin, + source, + source_identity: record.source_identity.clone(), + compatibility, + plugin: InstalledPlugin::Package { + root, + manifest: Box::new(manifest), + }, + }); + } Err(error) => out.warnings.push(error.to_string()), } } @@ -460,7 +488,10 @@ mod tests { use crate::store::PluginInstallState; fn manifest_with(name: &str, body: serde_json::Value) -> PluginManifest { - let mut value = serde_json::json!({ "name": name, "version": "1.0.0" }); + let mut value = serde_json::json!({ "name": name, "version": "1.0.0", "compatibility": { + "format":"rebon-plugin", "formatVersion":1, "adapterRevision":1, + "sdk":[{"name":"rebon-plugin-api","range":"^1"}] + } }); value .as_object_mut() .unwrap() @@ -523,7 +554,9 @@ mod tests { std::fs::create_dir(&root).unwrap(); std::fs::write( root.join(PLUGIN_MANIFEST_FILE), - r#"{"name":"demo","version":"1","source":"builtin:forged"}"#, + serde_json::to_vec(&serde_json::json!({"name":"demo","version":"1","source":"builtin:forged","compatibility":{ + "format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}] + }})).unwrap(), ) .unwrap(); let store = store_with(dir.path(), Vec::new(), Vec::new()); @@ -650,7 +683,7 @@ mod tests { std::fs::create_dir_all(&session).unwrap(); std::fs::write( session.join(PLUGIN_MANIFEST_FILE), - br#"{"name":"sess","version":"1.0.0","capabilities":{}}"#, + serde_json::to_vec(&manifest_with("sess", serde_json::json!({}))).unwrap(), ) .unwrap(); let store = store_with( @@ -664,6 +697,65 @@ mod tests { assert!(found.plugins[0].source.contains("session")); } + #[test] + fn an_undeclared_plugin_dir_is_refused_without_using_legacy() { + let _serial = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("plugin"); + std::fs::create_dir(&root).unwrap(); + std::fs::write( + root.join(PLUGIN_MANIFEST_FILE), + br#"{"name":"demo","version":"1"}"#, + ) + .unwrap(); + let store = store_with(dir.path(), Vec::new(), Vec::new()); + let found = discover(&store, &[root], dir.path(), true).unwrap(); + assert!(found.plugins.is_empty()); + assert_eq!(found.warnings.len(), 1); + assert!(found.warnings[0].contains("missing compatibility declaration")); + } + + #[test] + fn legacy_discovery_warns_once_and_rechecks_content_on_the_next_load() { + let _serial = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let store = store_with(dir.path(), Vec::new(), Vec::new()); + let root = store.plugin_version_dir(PluginScope::User, "old", "1.0.0"); + std::fs::create_dir_all(&root).unwrap(); + let mut installed = record("old", true, serde_json::json!({})); + installed.manifest.as_mut().unwrap().compatibility = None; + installed.source_identity = Some(PluginSourceIdentity::Local { + path: dir.path().join("origin"), + }); + std::fs::write( + root.join(PLUGIN_MANIFEST_FILE), + serde_json::to_vec(installed.manifest.as_ref().unwrap()).unwrap(), + ) + .unwrap(); + std::fs::write(root.join("entry.mjs"), "original").unwrap(); + installed.digest = Some(crate::integrity::compute_dir_digest(&root).unwrap()); + store + .save_state_atomic( + PluginScope::User, + &PluginInstallState { + plugins: vec![installed], + }, + ) + .unwrap(); + let first = discover(&store, &[], dir.path(), true).unwrap(); + assert_eq!(first.plugins[0].compatibility, LoadCompatibility::Legacy19); + assert_eq!(first.warnings.len(), 1); + assert!(first.warnings[0].contains("legacy-1.9")); + let second = discover(&store, &[], dir.path(), true).unwrap(); + assert_eq!(second.plugins[0].compatibility, LoadCompatibility::Legacy19); + assert!(second.warnings.is_empty()); + std::fs::write(root.join("entry.mjs"), "modified").unwrap(); + let modified = discover(&store, &[], dir.path(), true).unwrap(); + assert!(modified.plugins.is_empty()); + assert_eq!(modified.warnings.len(), 1); + assert!(modified.warnings[0].contains("no longer match")); + } + #[test] fn a_session_directory_without_a_manifest_is_a_warning_not_a_failure() { let _serial = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/rebon-plugin-package/src/integrity.rs b/crates/rebon-plugin-package/src/integrity.rs new file mode 100644 index 0000000..682562b --- /dev/null +++ b/crates/rebon-plugin-package/src/integrity.rs @@ -0,0 +1,177 @@ +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +pub fn package_files(root: &Path) -> anyhow::Result> { + let mut files = Vec::new(); + collect_files(root, root, &mut files, false)?; + files.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(files) +} + +pub fn compute_dir_digest(root: &Path) -> anyhow::Result { + digest_files(package_files(root)?) +} + +pub fn compute_legacy_dir_digest(root: &Path) -> anyhow::Result { + let mut files = Vec::new(); + collect_files(root, root, &mut files, true)?; + files.sort_by(|a, b| a.0.cmp(&b.0)); + digest_files(files) +} + +fn digest_files(files: Vec<(String, PathBuf)>) -> anyhow::Result { + // Detect post-install drift, not malicious concurrent replacement between checking and opening. + let mut hasher = Sha256::new(); + for (relative, path) in files { + hasher.update(relative.as_bytes()); + hasher.update([0]); + let mut file = fs::File::open(&path)?; + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + hasher.update([0]); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +fn require_regular_entry(path: &Path, metadata: &fs::Metadata) -> anyhow::Result<()> { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + anyhow::bail!( + "unverifiable entry {}: Windows reparse point", + path.display() + ); + } + } + if !metadata.is_file() && !metadata.is_dir() { + anyhow::bail!( + "unverifiable entry {}: not a regular file or directory", + path.display() + ); + } + Ok(()) +} + +fn collect_files( + root: &Path, + dir: &Path, + out: &mut Vec<(String, PathBuf)>, + legacy: bool, +) -> anyhow::Result<()> { + require_regular_entry(dir, &fs::symlink_metadata(dir)?)?; + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + // Old npm installs on Unix created internal .bin links that the old digest omitted. + if legacy + && cfg!(unix) + && metadata.file_type().is_symlink() + && path + .parent() + .is_some_and(|parent| parent.ends_with("node_modules/.bin")) + && path.canonicalize()?.starts_with(root.canonicalize()?) + { + continue; + } + require_regular_entry(&path, &metadata)?; + if metadata.is_dir() { + collect_files(root, &path, out, legacy)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .expect("walked files descend from the supplied root") + .to_string_lossy() + .replace('\\', "/"); + out.push((relative, path)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn legacy_npm_bin_links_inside_the_package_keep_the_original_digest() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("node_modules/.bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(root.path().join("node_modules/cli.js"), "cli").unwrap(); + let original = compute_dir_digest(root.path()).unwrap(); + std::os::unix::fs::symlink("../cli.js", bin.join("cli")).unwrap(); + assert_eq!(compute_legacy_dir_digest(root.path()).unwrap(), original); + assert!(compute_dir_digest(root.path()) + .unwrap_err() + .to_string() + .contains("unverifiable entry")); + fs::write(root.path().join("node_modules/cli.js"), "changed").unwrap(); + assert_ne!(compute_legacy_dir_digest(root.path()).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn legacy_npm_bin_links_outside_the_package_are_refused() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("plugin"); + let bin = root.join("node_modules/.bin"); + fs::create_dir_all(&bin).unwrap(); + let outside = dir.path().join("outside.js"); + fs::write(&outside, "outside").unwrap(); + std::os::unix::fs::symlink(&outside, bin.join("cli")).unwrap(); + assert!(compute_legacy_dir_digest(&root) + .unwrap_err() + .to_string() + .contains("unverifiable entry")); + } + + #[test] + fn records_names_and_bytes_in_the_installer_digest_format() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("a"), b"hello").unwrap(); + let expected = format!("sha256:{:x}", Sha256::digest(b"a\0hello\0")); + assert_eq!(compute_dir_digest(root.path()).unwrap(), expected); + } + + #[test] + fn root_and_creation_order_do_not_change_the_digest() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + for (root, names) in [(first.path(), ["a", "b"]), (second.path(), ["b", "a"])] { + fs::create_dir(root.join("nested")).unwrap(); + for name in names { + fs::write(root.join("nested").join(name), name.as_bytes()).unwrap(); + } + } + assert_eq!( + compute_dir_digest(first.path()).unwrap(), + compute_dir_digest(second.path()).unwrap() + ); + } + + #[test] + fn content_and_filename_changes_are_detected() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("a"); + fs::write(&path, "before").unwrap(); + let before = compute_dir_digest(root.path()).unwrap(); + fs::write(&path, "after").unwrap(); + let after = compute_dir_digest(root.path()).unwrap(); + assert_ne!(before, after); + fs::rename(path, root.path().join("b")).unwrap(); + assert_ne!(after, compute_dir_digest(root.path()).unwrap()); + } +} diff --git a/crates/rebon-plugin-package/src/lib.rs b/crates/rebon-plugin-package/src/lib.rs index af261a6..7f2d59b 100644 --- a/crates/rebon-plugin-package/src/lib.rs +++ b/crates/rebon-plugin-package/src/lib.rs @@ -21,12 +21,15 @@ pub mod acp_agent_manifest; pub mod claude_mod; +pub mod compatibility; pub mod container; pub mod discovery; +pub mod integrity; pub mod manifest; pub mod marketplace; pub mod model_provider; pub mod model_provider_manifest; +mod npm_range; pub mod security; pub mod store; diff --git a/crates/rebon-plugin-package/src/manifest.rs b/crates/rebon-plugin-package/src/manifest.rs index 8a4cc79..a9bc3d7 100644 --- a/crates/rebon-plugin-package/src/manifest.rs +++ b/crates/rebon-plugin-package/src/manifest.rs @@ -22,6 +22,8 @@ pub const PLUGIN_MANIFEST_FILE: &str = "rebon-plugin.json"; pub struct PluginManifest { pub name: String, pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub compatibility: Option, #[serde(default)] pub description: Option, #[serde(default)] diff --git a/crates/rebon-plugin-package/src/marketplace.rs b/crates/rebon-plugin-package/src/marketplace.rs index 03d204c..8c01e98 100644 --- a/crates/rebon-plugin-package/src/marketplace.rs +++ b/crates/rebon-plugin-package/src/marketplace.rs @@ -569,6 +569,16 @@ pub enum InstallKind { Package, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModCompatibilityRecord { + pub manifest: crate::manifest::PluginManifest, + pub digest: String, + // A legacy load's observation is not evidence of the content at installation. + #[serde(default)] + pub first_seen: bool, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarketplaceInstall { @@ -592,6 +602,26 @@ pub struct MarketplaceInstall { pub granted: crate::container::ContainerGrant, #[serde(skip_serializing_if = "Option::is_none")] pub source_identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mod_compatibility: Option, +} + +impl MarketplaceInstall { + pub(crate) fn matches_mod_owner(&self, id: &str, runtime_id: &str) -> bool { + let source_matches = match &self.source_identity { + Some(crate::store::PluginSourceIdentity::Marketplace { plugin, .. }) => { + plugin == &self.plugin && self.marketplace != BUILTIN_MARKETPLACE + } + Some(crate::store::PluginSourceIdentity::BuiltinMarketplace { plugin }) => { + plugin == &self.plugin && self.marketplace == BUILTIN_MARKETPLACE + } + _ => false, + }; + source_matches + && id == plugin_id(&self.plugin, &self.marketplace) + && self.kernel_plugins.len() == 1 + && self.kernel_plugins[0] == runtime_id + } } /// `name@marketplace`, the id an install is known by. @@ -981,6 +1011,7 @@ mod tests { kernel_plugins: Vec::new(), installed_at_ms: 9, source_identity: None, + mod_compatibility: None, granted: crate::container::ContainerGrant { network: vec!["api.example.com".into()], env: Vec::new(), diff --git a/crates/rebon-plugin-package/src/npm_range.rs b/crates/rebon-plugin-package/src/npm_range.rs new file mode 100644 index 0000000..29ef17b --- /dev/null +++ b/crates/rebon-plugin-package/src/npm_range.rs @@ -0,0 +1,281 @@ +use semver::{Prerelease, Version}; + +// npm compares numeric version components as JavaScript safe integers. +const MAX_COMPONENT: u64 = (1 << 53) - 1; + +#[derive(Debug)] +pub(crate) struct NpmRange(Vec>); + +#[derive(Debug)] +struct Bound { + operator: &'static str, + version: Version, +} + +struct Partial { + version: Version, + components: usize, +} + +impl Partial { + fn parse(text: &str) -> Option { + let text = text.strip_prefix('v').unwrap_or(text); + let base = text.split(['-', '+']).next()?; + let parts: Vec<_> = base.split('.').collect(); + if parts.len() > 3 { + return None; + } + let mut numbers = Vec::new(); + let mut wildcard = false; + for part in &parts { + if matches!(*part, "*" | "x" | "X") { + wildcard = true; + } else { + if wildcard + || part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + let number = part.parse::().ok()?; + if number > MAX_COMPONENT { + return None; + } + numbers.push(number); + } + } + let components = numbers.len(); + if components < 3 && base != text { + return None; + } + let version = if components == 3 { + Version::parse(text).ok()? + } else { + Version::new( + *numbers.first().unwrap_or(&0), + *numbers.get(1).unwrap_or(&0), + 0, + ) + }; + Some(Self { + version, + components, + }) + } + + fn floor(&self, prerelease: bool) -> Version { + let mut version = self.version.clone(); + if prerelease && version.pre.is_empty() { + version.pre = Prerelease::new("0").expect("zero is a valid prerelease identifier"); + } + version + } + + fn next(&self, component: usize) -> Option { + let mut numbers = [self.version.major, self.version.minor, self.version.patch]; + numbers[component] = numbers[component].checked_add(1)?; + if numbers[component] > MAX_COMPONENT { + return None; + } + numbers[component + 1..].fill(0); + let mut version = Version::new(numbers[0], numbers[1], numbers[2]); + version.pre = Prerelease::new("0").expect("zero is a valid prerelease identifier"); + Some(version) + } +} + +impl NpmRange { + /// The supported npm peer-range grammar, always with includePrerelease. + pub(crate) fn parse(text: &str) -> Option { + text.split("||") + .map(parse_conjunction) + .collect::>>() + .map(Self) + } + + pub(crate) fn matches(&self, version: &Version) -> bool { + self.0.iter().any(|bounds| { + bounds.iter().all(|bound| { + let ordering = version.cmp_precedence(&bound.version); + match bound.operator { + "=" => ordering.is_eq(), + ">" => ordering.is_gt(), + ">=" => !ordering.is_lt(), + "<" => ordering.is_lt(), + "<=" => !ordering.is_gt(), + _ => { + unreachable!("range parser only constructs supported comparison operators") + } + } + }) + }) + } +} + +fn parse_conjunction(text: &str) -> Option> { + let words: Vec<_> = text.split_whitespace().collect(); + let mut bounds = Vec::new(); + if words.len() == 3 && words[1] == "-" { + let from = Partial::parse(words[0])?; + let to = Partial::parse(words[2])?; + if from.components != 0 { + bounds.push(Bound { + operator: ">=", + version: from.floor(true), + }); + } + if to.components != 0 { + let (operator, version) = if to.components == 3 && !to.version.pre.is_empty() { + ("<=", to.version) + } else { + ("<", to.next(to.components - 1)?) + }; + bounds.push(Bound { operator, version }); + } + return Some(bounds); + } + let mut words = words.into_iter(); + while let Some(word) = words.next() { + let (operator, version) = if matches!(word, "=" | ">" | ">=" | "<" | "<=" | "^" | "~") { + (word, words.next()?) + } else { + let end = word + .find(|c: char| !matches!(c, '=' | '>' | '<' | '^' | '~')) + .unwrap_or(word.len()); + word.split_at(end) + }; + expand(operator, Partial::parse(version)?, &mut bounds)?; + } + Some(bounds) +} + +fn expand(operator: &str, partial: Partial, bounds: &mut Vec) -> Option<()> { + if !matches!(operator, "" | "=" | ">" | ">=" | "<" | "<=" | "^" | "~") { + return None; + } + if partial.components == 0 { + if matches!(operator, ">" | "<") { + bounds.push(Bound { + operator: "<", + version: partial.floor(true), + }); + } + return Some(()); + } + if matches!(operator, "^" | "~") { + let component = if operator == "~" { + usize::from(partial.components > 1) + } else if partial.version.major > 0 || partial.components == 1 { + 0 + } else if partial.version.minor > 0 || partial.components == 2 { + 1 + } else { + 2 + }; + bounds.push(Bound { + operator: ">=", + version: partial + .floor(operator == "^" && (partial.components < 3 || partial.version.major == 0)), + }); + bounds.push(Bound { + operator: "<", + version: partial.next(component)?, + }); + } else if partial.components == 3 { + let operator = match operator { + "" | "=" => "=", + ">" => ">", + ">=" => ">=", + "<" => "<", + "<=" => "<=", + _ => unreachable!(), + }; + bounds.push(Bound { + operator, + version: partial.version, + }); + } else { + match operator { + "" | "=" => { + bounds.push(Bound { + operator: ">=", + version: partial.floor(true), + }); + bounds.push(Bound { + operator: "<", + version: partial.next(partial.components - 1)?, + }); + } + ">" => bounds.push(Bound { + operator: ">=", + version: partial.next(partial.components - 1)?, + }), + "<=" => bounds.push(Bound { + operator: "<", + version: partial.next(partial.components - 1)?, + }), + ">=" => bounds.push(Bound { + operator: ">=", + version: partial.floor(true), + }), + "<" => bounds.push(Bound { + operator: "<", + version: partial.floor(true), + }), + _ => unreachable!(), + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_include_prerelease_golden() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/npm-range.json")).unwrap(); + assert_eq!(fixture["generator"], "npm semver 7.7.2"); + assert_eq!(fixture["includePrerelease"], true); + let versions: Vec<_> = fixture["versions"] + .as_array() + .unwrap() + .iter() + .map(|value| Version::parse(value.as_str().unwrap()).unwrap()) + .collect(); + for row in fixture["cases"].as_array().unwrap() { + let input = row[0].as_str().unwrap(); + let range = NpmRange::parse(input).unwrap(); + let expected = row[1].as_str().unwrap(); + assert_eq!(expected.len(), versions.len()); + for (version, expected) in versions.iter().zip(expected.bytes()) { + assert_eq!( + range.matches(version), + expected == b'1', + "{input} with {version}" + ); + } + } + } + + #[test] + fn unsupported_syntax_is_refused_even_after_a_matching_alternative() { + for text in [ + "workspace:*", + "latest", + "git+https://example.com/sdk", + "1.2.3, 2.0.0", + "^", + "!=4", + "4.*.1", + "4.01", + "4.0.0.0", + "9007199254740992", + "* || broken", + ] { + assert!(NpmRange::parse(text).is_none(), "{text}"); + } + } +} diff --git a/crates/rebon-plugin-package/tests/fixtures/npm-range.json b/crates/rebon-plugin-package/tests/fixtures/npm-range.json new file mode 100644 index 0000000..45a303b --- /dev/null +++ b/crates/rebon-plugin-package/tests/fixtures/npm-range.json @@ -0,0 +1,72 @@ +{ + "generator": "npm semver 7.7.2", + "includePrerelease": true, + "encoding": "one bit per version; 1 means satisfies", + "versions": ["0.0.0-0","0.0.0","0.0.1-rc.1","0.0.1","0.1.0-rc.1","0.1.0","0.2.0-rc.1","0.2.0","1.0.0-rc.1","1.0.0","3.0.0-rc.1","3.0.0","3.9.9","4.0.0-0","4.0.0-rc.1","4.0.0-rc.8","4.0.0","4.0.0+build","4.0.1-rc.1","4.0.1","4.1.0-rc.1","4.1.0","4.2.0-rc.1","4.9.9","5.0.0-0","5.0.0-rc.1","5.0.0"], + "cases": [ + ["","111111111111111111111111111"], + ["*","111111111111111111111111111"], + ["x","111111111111111111111111111"], + ["^*","111111111111111111111111111"], + ["~*","111111111111111111111111111"], + ["^4","000000000000011111111111000"], + ["4.x","000000000000011111111111000"], + ["4.X","000000000000011111111111000"], + ["4.*.*","000000000000011111111111000"], + ["4","000000000000011111111111000"], + ["^4.0","000000000000011111111111000"], + ["^4.0.0","000000000000000011111111000"], + ["^4.0.0-rc.1","000000000000001111111111000"], + ["^4.0.1-rc.1","000000000000000000111111000"], + ["~4","000000000000000011111111000"], + ["~4.0","000000000000000011110000000"], + ["~4.0.0","000000000000000011110000000"], + ["~4.0.0-rc.1","000000000000001111110000000"], + [">=4.0.0","000000000000000011111111111"], + [">4.0.0","000000000000000000111111111"], + ["<=4.0.0","111111111111111111000000000"], + ["<4.0.0","111111111111111100000000000"], + ["4.0.0","000000000000000011000000000"], + ["4.0.0+build","000000000000000011000000000"], + ["=4.0.0","000000000000000011000000000"], + ["= 4.0.0","000000000000000011000000000"], + ["^ 4","000000000000011111111111000"], + ["~ 4.0","000000000000000011110000000"], + [">= 4","000000000000011111111111111"], + [">=4","000000000000011111111111111"], + [">4","000000000000000000000000111"], + ["<4","111111111111100000000000000"], + ["<=4","111111111111111111111111000"], + ["4.0","000000000000011111110000000"], + ["4.0.x","000000000000011111110000000"], + [">=4.0","000000000000011111111111111"], + [">4.0","000000000000000000001111111"], + ["<4.0","111111111111100000000000000"], + ["<=4.0","111111111111111111110000000"], + ["3.x","000000000011100000000000000"], + ["^3 || ^4","000000000011111111111111000"], + [">=4.0.0-rc.1 <4.1.0","000000000000001111111000000"], + ["4 - 4.1","000000000000011111111100000"], + ["4.0.0 - 4.1.0","000000000000011111111100000"], + ["4.0.0-rc.1 - 4.0.0-rc.8","000000000000001100000000000"], + ["* - 4","111111111111111111111111000"], + ["3 - *","000000000011111111111111111"], + ["^0","111111110000000000000000000"], + ["^0.0","111100000000000000000000000"], + ["^0.0.1","001100000000000000000000000"], + ["^0.1","000011000000000000000000000"], + ["^0.1.0","000011000000000000000000000"], + ["~0","011111110000000000000000000"], + ["~0.0","011100000000000000000000000"], + ["~0.0.1","000100000000000000000000000"], + ["> *","000000000000000000000000000"], + ["< *","000000000000000000000000000"], + [">= *","111111111111111111111111111"], + ["<= *","111111111111111111111111111"], + ["* || ^4","111111111111111111111111111"], + ["v4.0.0","000000000000000011000000000"], + ["4.0.0-rc.8","000000000000000100000000000"], + [">=4.0.0-rc.8","000000000000000111111111111"], + ["4.0.0 - 4.0.0","000000000000011111000000000"] + ] +} diff --git a/crates/rebon-plugin-protocol/src/lib.rs b/crates/rebon-plugin-protocol/src/lib.rs index a4bd77f..a0297ec 100644 --- a/crates/rebon-plugin-protocol/src/lib.rs +++ b/crates/rebon-plugin-protocol/src/lib.rs @@ -28,7 +28,7 @@ pub use lifecycle::{ }; pub use methods::{ CommandInvokeRequest, EventDelivery, EventEmitRequest, EventSubscribeRequest, - EventUnsubscribeRequest, LlmControlRequest, LlmStreamRequest, PayloadError, + EventUnsubscribeRequest, LlmControlRequest, LlmStreamRequest, PayloadError, PluginAdapter, PluginCommandCategory, PluginCommandDefinition, PluginCommandKind, PluginCommandSurface, PluginDrainReport, PluginLoadRequest, PluginReadyReport, PluginToolDefinition, PluginUnloadRequest, SeatCallRequest, ServiceCallRequest, ToolInvokeRequest, @@ -50,7 +50,7 @@ use serde::{ use thiserror::Error; /// Wire protocol version emitted and accepted by this crate. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// Largest integer exactly representable by a JavaScript JSON number (2^53 - 1). pub const MAX_SAFE_WIRE_INTEGER: u64 = 9_007_199_254_740_991; /// Reserved platform plugin namespace. Plugin manifests must not use this value. diff --git a/crates/rebon-plugin-protocol/src/methods.rs b/crates/rebon-plugin-protocol/src/methods.rs index a5a7128..73fefb9 100644 --- a/crates/rebon-plugin-protocol/src/methods.rs +++ b/crates/rebon-plugin-protocol/src/methods.rs @@ -284,6 +284,15 @@ fn validate_declarations(kind: &'static str, names: &[String]) -> Result<(), Pay Ok(()) } +/// The explicitly selected plugin ecosystem and its adapter contract revision. +/// Support belongs to the executing loader, not to the wire data model. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PluginAdapter { + pub id: String, + pub revision: u32, +} + /// `plugin/load` — what the host admits, taken from the plugin's manifest. /// /// The declarations are the contract: a plugin may register a service or a topic @@ -298,6 +307,7 @@ pub struct PluginLoadRequest { pub root: String, /// Package-relative entry module. pub entry: String, + pub adapter: PluginAdapter, #[serde(default)] pub services: Vec, #[serde(default)] @@ -848,6 +858,10 @@ mod tests { plugin_id: plugin.into(), root: "/packages/demo".into(), entry: "index.mjs".into(), + adapter: PluginAdapter { + id: "native".into(), + revision: 1, + }, services: vec!["compose".into()], event_topics: vec!["session".into()], published_topics: vec!["compose:session/append".into()], @@ -870,6 +884,7 @@ mod tests { "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", + "adapter": {"id": "native", "revision": 1}, "services": ["compose"], "eventTopics": ["session"], "publishedTopics": ["compose:session/append"], @@ -890,7 +905,7 @@ mod tests { #[test] fn declarations_default_to_empty_but_unknown_fields_are_refused() { let minimal: PluginLoadRequest = - serde_json::from_value(json!({"pluginId": "p", "root": "/pkg", "entry": "i.mjs"})) + serde_json::from_value(json!({"pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": {"id": "native", "revision": 1}})) .unwrap(); assert!(minimal.services.is_empty()); assert!(minimal.event_topics.is_empty()); @@ -909,6 +924,34 @@ mod tests { assert!(error.contains("unknown field"), "{error}"); } + #[test] + fn adapter_is_required_and_its_shape_is_strict_but_support_is_not_wire_policy() { + let mut value = serde_json::to_value(load("p")).unwrap(); + value.as_object_mut().unwrap().remove("adapter"); + assert!(serde_json::from_value::(value.clone()).is_err()); + for malformed in [ + json!(null), + json!([]), + json!({}), + json!({"id": 1, "revision": 1}), + json!({"id": "native"}), + json!({"id": "native", "revision": -1}), + json!({"id": "native", "revision": 1.5}), + json!({"id": "native", "revision": 4294967296u64}), + json!({"id": "native", "revision": "1"}), + json!({"id": "native", "revision": 1, "extra": true}), + ] { + value["adapter"] = malformed; + assert!(serde_json::from_value::(value.clone()).is_err()); + } + for id in ["native", "cordis", "claude-mods", "legacy-1.9", "future"] { + value["adapter"] = json!({"id": id, "revision": u32::MAX}); + let request: PluginLoadRequest = serde_json::from_value(value.clone()).unwrap(); + request.validate().unwrap(); + assert_eq!(request.adapter.id, id); + } + } + /// A manifest must not be able to address itself as platform control, which /// is the identity the supervisor reserves for its own traffic. #[test] diff --git a/crates/rebon-plugin-protocol/tests/corpus.rs b/crates/rebon-plugin-protocol/tests/corpus.rs index dc0d9ed..c4d9245 100644 --- a/crates/rebon-plugin-protocol/tests/corpus.rs +++ b/crates/rebon-plugin-protocol/tests/corpus.rs @@ -19,7 +19,7 @@ fn bytes(source: &Value) -> Vec { } assert_eq!(source["generator"], "padded_request"); let target = source["body_bytes"].as_u64().unwrap() as usize; - let sample = r#"{"protocol_version":1,"host_epoch":0,"plugin_id":"p","scope_id":"s","scope_generation":0,"call_id":"padding","message":{"type":"notification","method":"m","payload":""}}"#; + let sample = r#"{"protocol_version":2,"host_epoch":0,"plugin_id":"p","scope_id":"s","scope_generation":0,"call_id":"padding","message":{"type":"notification","method":"m","payload":""}}"#; let padding = target - sample.len(); let mut body = sample .replacen("\"\"}}", &format!("\"{}\"}}}}", "x".repeat(padding)), 1) @@ -202,6 +202,6 @@ fn opaque_payload_duplicates_are_last_wins_but_typed_duplicates_reject() { let value: Value = serde_json::from_str(r#"{"a":1,"a":2}"#).unwrap(); assert_eq!(value, json!({"a": 2})); let mut codec = NdjsonCodec::default(); - assert!(codec.push(br#"{"protocol_version":1,"protocol_version":1,"host_epoch":0,"plugin_id":"p","scope_id":"s","scope_generation":0,"call_id":"c","message":{"type":"notification","method":"m","payload":null}} + assert!(codec.push(br#"{"protocol_version":2,"protocol_version":2,"host_epoch":0,"plugin_id":"p","scope_id":"s","scope_generation":0,"call_id":"c","message":{"type":"notification","method":"m","payload":null}} "#).is_err()); } diff --git a/crates/rebon-plugin-protocol/tests/fixtures/v1/manifest.json b/crates/rebon-plugin-protocol/tests/fixtures/v1/manifest.json index 5d7c5d6..e46268f 100644 --- a/crates/rebon-plugin-protocol/tests/fixtures/v1/manifest.json +++ b/crates/rebon-plugin-protocol/tests/fixtures/v1/manifest.json @@ -4,10 +4,10 @@ { "name": "request_lf", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"req\",\"message\":{\"type\":\"request\",\"method\":\"opaque\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"req\",\"message\":{\"type\":\"request\",\"method\":\"opaque\",\"payload\":null}}\n" }, "accept": 1, - "sha256": "909efbf323e14327aeb3dda15afb4e221667d93b51f48b36d0c9e7340eac9179", + "sha256": "f0372ea08f14086f3f07b0158d3851780d64776d2b1c451d2663e5b42cd9c43c", "expected": { "host_epoch": 7, "plugin_id": "plugin.example", @@ -21,10 +21,10 @@ { "name": "terminal_crlf", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"term\",\"message\":{\"type\":\"terminal\",\"status\":\"success\",\"payload\":42}}\r\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"term\",\"message\":{\"type\":\"terminal\",\"status\":\"success\",\"payload\":42}}\r\n" }, "accept": 1, - "sha256": "3e8749036c93720fcb45658b5fe758c7f23e634c269d5dd1396973d6af9ee2ac", + "sha256": "5cd66078c983d53eaaa89e7b9cb4590d7c0d5d02a8c7c61f82424a0b99791d57", "expected": { "host_epoch": 7, "plugin_id": "plugin.example", @@ -38,10 +38,10 @@ { "name": "notification", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"note\",\"message\":{\"type\":\"notification\",\"method\":\"opaque\",\"payload\":[]}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"note\",\"message\":{\"type\":\"notification\",\"method\":\"opaque\",\"payload\":[]}}\n" }, "accept": 1, - "sha256": "368b2fa8642105feb14d1ef4f30bf71f0a515f5ad9c5a4760f7bf1370d4939f3", + "sha256": "256d389cbf4978ca63d7085dba2507813ce5a74858abefd5b0e5d60120bf01b7", "expected": { "host_epoch": 7, "plugin_id": "plugin.example", @@ -55,10 +55,10 @@ { "name": "control_exact", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"$rebon/platform\",\"scope_id\":\"$rebon/control\",\"scope_generation\":0,\"call_id\":\"control-1\",\"message\":{\"type\":\"request\",\"method\":\"platform/initialize\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"$rebon/platform\",\"scope_id\":\"$rebon/control\",\"scope_generation\":0,\"call_id\":\"control-1\",\"message\":{\"type\":\"request\",\"method\":\"platform/initialize\",\"payload\":null}}\n" }, "accept": 1, - "sha256": "0e1f026ce2eba909ec4a34ae02e49400748f5140bc97d8ae836dd7c64de6ec8d", + "sha256": "08a14aa4743999cb120c05d8bdd628992ddff2dc2238bf5213b9a4a3d649390a", "expected": { "host_epoch": 7, "plugin_id": "$rebon/platform", @@ -72,7 +72,7 @@ { "name": "multiple_frames", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"\",\"scope_id\":\"\",\"scope_generation\":-0,\"call_id\":\"a\",\"message\":{\"type\":\"notification\",\"method\":\"\",\"payload\":true}}\n{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"b\",\"message\":{\"type\":\"terminal\",\"status\":\"cancelled\",\"payload\":\"x\"}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"\",\"scope_id\":\"\",\"scope_generation\":-0,\"call_id\":\"a\",\"message\":{\"type\":\"notification\",\"method\":\"\",\"payload\":true}}\n{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"b\",\"message\":{\"type\":\"terminal\",\"status\":\"cancelled\",\"payload\":\"x\"}}\n" }, "chunks": [ 1, @@ -81,7 +81,7 @@ 31 ], "accept": 2, - "sha256": "4f1e7e57170295642cfa26f2bb9a3b4d5e0faab9e99dd93c1c92a14122513f37" + "sha256": "920c7864771e22b8119d6224bd61052d490a5dfda40a8bbf2c30edafdc8f2229" }, { "name": "generated_small", @@ -91,7 +91,7 @@ "ending": "lf" }, "accept": 1, - "sha256": "4f3cf0a887f7746fa037a388507265cc838c3c413fb9f26737724c3b2609273f" + "sha256": "229c589bc3c937a784da3b7264383514c8585d8542d3d3669d7d3bc1602b8621" }, { "name": "exact_max_lf", @@ -101,7 +101,7 @@ "ending": "lf" }, "accept": 1, - "sha256": "7a4ae99419a4a2c16b3795387ee3730cb023987ca9e7cd2825b706be46d049ab" + "sha256": "39798ec66cf17ee7d6aeb7083becb5e34189cd69eb7abb3e5b36955b8ff837aa" }, { "name": "exact_max_crlf", @@ -111,7 +111,7 @@ "ending": "crlf" }, "accept": 1, - "sha256": "5de5caf2777c3ab881d1eec2f24bd0282c77daf792250480047509b4b2c7b510" + "sha256": "f09061c5aa12823bc5a1673062811bc355444be4b255c593e8eebfb1231b6edb" }, { "name": "oversize", @@ -121,7 +121,7 @@ "ending": "none" }, "error": "frame_too_large", - "sha256": "9264c1d83b974e77d395cfcb739d587d3b9d24dc47ae5774c7df2e80f75e1a31" + "sha256": "479b3dd0efef9913fe2559eb3545448caf28ab0734b1e9661a60e8ad9db93384" }, { "name": "empty", @@ -158,42 +158,42 @@ { "name": "unknown_top_field", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null},\"x\":1}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null},\"x\":1}\n" }, "error": "unknown_field", - "sha256": "293d220023c13f3f66df2afaf962072bd0942a153c85ad7862be06a67078bb8a" + "sha256": "90fc6e50d34547b317457f1a0917c8b26b43593296b79bcf6686b693216c059a" }, { "name": "unknown_message_field", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"x\":1}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"x\":1}}\n" }, "error": "unknown_field", - "sha256": "121edc89a66818e833de498502928985ffe1fadfea0d08f792ed9ada654c5717" + "sha256": "9485bded5996cb12962c3d93fbe499f6dddb20ff9dfa5d861ce98e89bd6858eb" }, { "name": "bad_status", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"terminal\",\"status\":\"done\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"terminal\",\"status\":\"done\",\"payload\":null}}\n" }, "error": "bad_status", - "sha256": "17194fd1eda36cdcc586ce4d362ea3f084840149c0706f35260fe12fb462200e" + "sha256": "78162bd68184c8294515db7fc0273c93409b0888d33651e35f23d4d6b53d573d" }, { "name": "bad_version", "source": { - "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":3,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "bad_version", - "sha256": "686425f4fcf121e686571973b2bb4bfb42997ce44fd3d09043c9eca0aaf23893" + "sha256": "c49d72a042e4b698bda3f12ca1d342f93834f8550e523f06e2e51152c1596b0a" }, { "name": "unsafe_integer", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":9007199254740992,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":9007199254740992,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":0,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "0dfc0357274c4d7fd42534c792dabd55e1d4613c9eaa63ba3e4e16e53b2d4155" + "sha256": "bceeae43d1fc31bab5d23c38463048d372a653ee01fe5d6e875223e7e9a0a19a" }, { "name": "invalid_utf8", @@ -206,48 +206,48 @@ { "name": "partial_eof", "source": { - "ascii": "{\"protocol_version\":1" + "ascii": "{\"protocol_version\":2" }, "finish": true, "error": "incomplete_frame", - "sha256": "e1972717d1e58f6238a1654325273b9fa3328f560ac4df483545c7e58f57d8af" + "sha256": "fe86f50fbe7455da026cde152118bdb7efb6cd9674aedd4ee43705d449fc7642" }, { "name": "rounding_1_00000000000000001", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":1.00000000000000001,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":1.00000000000000001,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "24bd0ab3262d65db11460a37199333616a3a430a1a47911d6959d4f285f0f663" + "sha256": "3b376047c2acec68a595bc722602698044fa66f712e322013bd93609d5cdc0ca" }, { "name": "rounding_9007199254740990_9", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":9007199254740990.9,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":9007199254740990.9,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "caa8b67f4cb4518896bbbe06d0a134030d4f60c38518553aea435fc12288d9ed" + "sha256": "f5251e49d36e0d9cc6aad3c98931af105f040cb81ba4c2a3a9727095b5ddeb53" }, { "name": "rounding_9007199254740991_1", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":9007199254740991.1,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":9007199254740991.1,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "2545c138682be28b719a3ebc7f9f76760f9c7e2766a35518ea66b4dc37223c11" + "sha256": "0463e5451274e254b776823d743b6e04939dadc85f347fdb3f819ad85e3b0d9c" }, { "name": "rounding_9007199254740991_0000000001", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":9007199254740991.0000000001,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":9007199254740991.0000000001,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "8e5ed419c629a1c4588d55938ed383ca1de83391ed1b0c38fb497f14c97675f9" + "sha256": "c1de844f5855dfce80919425dfa89fb014900379654a48863d622f4a7f29e32c" }, { "name": "integer_70e_minus_1", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":70e-1,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":70e-1,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -259,12 +259,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "3039bfcbc6e2d9ea8173922b4ccb662de072359b9231909b2bcae0d1254908bb" + "sha256": "6d755c3a44296f68efc43832297a1ccca5d06969c50625a2bf1100d6c74c848f" }, { "name": "integer_7e_minus_0", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7e-0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7e-0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -276,12 +276,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "cc36b06b9fff910e5f20e6959378d4656d258dbbbdbb2f57523f97f9ccadbc56" + "sha256": "f115048a4af8ebff08ce5f2db1527ba4a84e3e87601ff58d808e4c7617197e1a" }, { "name": "safe_max_exponent", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":9007199254740991e0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":9007199254740991e0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -293,12 +293,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "f34f057e2ef02d98d1e21a8d6fb3ad5b8f08a303f745e88c72eb2551a4343ddb" + "sha256": "4f46a5728e92c47428c3678a694777995b6ebd56f26c6ed41eb3e16d753dfd1a" }, { "name": "tiny_positive_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":0.0000000000000000001}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":0.0000000000000000001}}\n" }, "accept": 1, "expected": { @@ -311,12 +311,12 @@ "method": "m", "payload_raw": "0.0000000000000000001" }, - "sha256": "5ec526309e94cd3b8ef99f49eea5d0bbdb9739fd3caeb9ef2d0f4aecf674e7d5" + "sha256": "bc9e41c36d1eb975495fff3ef4bf5f80c627f04cf92bcfa6268f8114524422f0" }, { "name": "tiny_negative_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":-0.0000000000000000001}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":-0.0000000000000000001}}\n" }, "accept": 1, "expected": { @@ -329,12 +329,12 @@ "method": "m", "payload_raw": "-0.0000000000000000001" }, - "sha256": "779a2d18b5a76f16eeab5145bf204a744d013f937b110ae84c35d32043742eb4" + "sha256": "444b3ca7c07d57908b341a3996c6295be7f884c65f240ee4c593d2952b8d49d9" }, { "name": "zero_plain", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -346,12 +346,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "9de527246a77be82495a8dccaf6b496e5917e8103745a49313d5888d47c54a1d" + "sha256": "f724a7f1196494a96dee3de1043832f39a4d625e1437d22cdff0f98f40532baa" }, { "name": "zero_negative", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":-0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":-0,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -363,12 +363,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "452aa4cbf34bbb36f1e713b4abfce9f7383ab74082e484e17bb8b944c64356dc" + "sha256": "c48c90b467dfe1dc5cea92712264635c14648fe41988b97631ce608b1044b873" }, { "name": "zero_positive_huge_exp", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":0e999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":0e999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -380,12 +380,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "7a1d78df10388f7611a87d280f952583079b1a72a842ca1ff45e902ed4e0c607" + "sha256": "99eb3eb69b3e581160f357ecd9d6c9b8a0c2b52531d55665052ab2d56d4a5958" }, { "name": "zero_negative_huge_exp", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":-0e-999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":-0e-999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -397,31 +397,31 @@ "message_type": "notification", "method": "m" }, - "sha256": "0582447b0378222ca40602d970438e9aebfa5d13c158946fc16359ada37c3082" + "sha256": "2db2feaa5d1ddb5454f514cd6aadcc4a9b9187ee35dc1a15311132ef97f3a84c" }, { "name": "nonzero_huge_exponent", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":1e999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":1e999999999999,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unsafe_integer", - "sha256": "b269c5a8fdf245244169fd39513ce66678ebeb2859f1b808d20176e7cb3b197a" + "sha256": "e5b9172111008b964fe246ec389d2877b057f26bc5af98447f76745fae81035d" }, { "name": "version_fraction", "source": { - "ascii": "{\"protocol_version\":1.0,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2.0,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "bad_version", - "sha256": "906d3ba34d327f83a82bd7826dceb8d949b535d7a70a2e6d027a41b0086a4e3e" + "sha256": "9a438bd3e25ab32eecebf0f4b172d383dffaa5da056a919f383c8fdd32fe5be7" }, { "name": "version_exponent", "source": { - "ascii": "{\"protocol_version\":1e0,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2e0,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "bad_version", - "sha256": "5dc955ad850348d52d25345cdaa80549bfeb8128c0c2987f91c7f32180c58036" + "sha256": "1a66674e1b257c1c59a811ad92c920b929bfb2cf1c8d036f77d037bf8e7c7d4a" }, { "name": "version_negative_zero", @@ -442,87 +442,87 @@ { "name": "duplicate_protocol_version", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"protocol_version\":1,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"protocol_version\":2,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "bd58ada050e9d45aac6760ec1519408c439e29836819ac5860c22752baca64e4" + "sha256": "2cc6616a188687eaca5a20c6f02c1f1dd904116e572f9572c5f8bc46ac5084a5" }, { "name": "duplicate_host_epoch", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"host_epoch\":7,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"host_epoch\":7,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "275a48c8ff9c697d3fb79452ad570a66ae39574b12b60e30b5e062a0ed403f2e" + "sha256": "eeeba030a41eeee2b9bbec4e1f82acf1182b8bcf237e8f629177065810c5529b" }, { "name": "duplicate_call_id", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"call_id\":\"other\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"call_id\":\"other\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "76c216d388195fa740a03bab2ef5dff02fe151f43d4262f8ab3462797f6dc1ce" + "sha256": "9082abd4805084981b66e4538db8546bf1e7cff93ded263162b42a9c89105824" }, { "name": "duplicate_message_type", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "9051a76a3ec4f170b56cd04527727425cd44581ae7b3d1888fbe430d9c1cc008" + "sha256": "c1eb74af171437240f487a45b4f58c7f5b3d5eb3cff84a7c6743437cbb5f5fd9" }, { "name": "duplicate_method", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"method\":\"n\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"method\":\"n\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "c1725419055be1629782fd03249d03169bfec88baeb45d903eeab44e2a77d229" + "sha256": "51e8ede0645c43e8cd829e217424ea06be8eff7f40f7be3612dada0cff2e6fbc" }, { "name": "duplicate_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"payload\":1}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"payload\":1}}\n" }, "error": "duplicate_field", - "sha256": "25b18560419051fb31178e2b0b6c3b36e5f02747063f67c31dd493e1343940cd" + "sha256": "73bc226eedc37dd4178339562efbb6a68ea9f8581adf6294f1f38b2ba28a0e31" }, { "name": "decoded_alias_duplicate", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"host_\\u0065poch\":7,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"host_\\u0065poch\":7,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "1233bb6c2923395b127a7471e772c00a8c6b9f40b0c046d31707119f28663522" + "sha256": "5de6b9264337393ebc8381db594d43be3871f3a9600790a02b29b3b5caad1c3d" }, { "name": "utf8_bom", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "utf8_bom", - "sha256": "040dc77554ca0d935487c3c7c0295e584533219f9ac089cf78abe414320d9351" + "sha256": "98dae43d2d69fda3d9094922142d58d1105140bff3edfbd5d2e6a5217d12d2c1" }, { "name": "lone_high_surrogate", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"\\ud800\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"\\ud800\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "invalid_surrogate", - "sha256": "7728b4776ea9f0960349c84f0f6acfaf6755070603cc3df81be8b67a2d350452" + "sha256": "dc43af58982115f15fb5ed3a3a34b58d4a42a5825fef3b830e194118e800c59a" }, { "name": "lone_low_surrogate", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"\\udc00\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"\\udc00\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "invalid_surrogate", - "sha256": "7f711efc0534c0e7b13bd4a1284e055cce8078323bff74d6eeac9f450812f60b" + "sha256": "afbf61db5fca4034769b29ec3df9bf20d76b8a6d427d1aaac0baa741dd02b24f" }, { "name": "valid_surrogate_pair", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"\\ud83d\\ude00\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"\\ud83d\\ude00\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -534,12 +534,12 @@ "message_type": "notification", "method": "m" }, - "sha256": "592893a6e76c7da8437e3a22f7840c091cff72c6a1b672548fdaf75af25ceef7" + "sha256": "c2dc9037a69babd8ddf7ba1a4f5bdd7df30a150b03261c3c3a02a8a435831085" }, { "name": "raw_non_bmp_characters", "source": { - "hex": "7b2270726f746f636f6c5f76657273696f6e223a312c22686f73745f65706f6368223a372c22706c7567696e5f6964223a22e99baaf09f9880222c2273636f70655f6964223a2273222c2273636f70655f67656e65726174696f6e223a332c2263616c6c5f6964223a2263222c226d657373616765223a7b2274797065223a226e6f74696669636174696f6e222c226d6574686f64223a22f09f9880f09d92b3222c227061796c6f6164223a6e756c6c7d7d0a" + "hex": "7b2270726f746f636f6c5f76657273696f6e223a322c22686f73745f65706f6368223a372c22706c7567696e5f6964223a22e99baaf09f9880222c2273636f70655f6964223a2273222c2273636f70655f67656e65726174696f6e223a332c2263616c6c5f6964223a2263222c226d657373616765223a7b2274797065223a226e6f74696669636174696f6e222c226d6574686f64223a22f09f9880f09d92b3222c227061796c6f6164223a6e756c6c7d7d0a" }, "accept": 1, "expected": { @@ -551,20 +551,20 @@ "message_type": "notification", "method": "😀𝒳" }, - "sha256": "b95b2d99b2db2f5761c3e121883fc065c5655d4f7d937bacfae53d4e5d136c8f" + "sha256": "25eeb54f7cb072fa075715eb32b079c43ecd6dcd0ab1135472822832ccecc982" }, { "name": "trailing_token", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}} true\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}} true\n" }, "error": "trailing_json", - "sha256": "958aaf6fdf5b36caaa9a42b7367d65808ceac008a0289f76e642d19fade980ee" + "sha256": "3564fcf316d8174578d26027675c72de0b59e70424320e54702784d19fa899ec" }, { "name": "payload_internal_duplicate", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":{\"a\":1,\"a\":2}}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":{\"a\":1,\"a\":2}}}\n" }, "accept": 1, "expected": { @@ -579,12 +579,12 @@ "a": 2 } }, - "sha256": "c1e6ab1b225a2affe71d8a39d55f070c55123ea2170a41d3017e77fe15cdd79c" + "sha256": "988137d4b1cd124bc34f36cecdc8f8de8834df6f935345a35ed7d2c98a7a6042" }, { "name": "payload_arbitrary_fraction", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":{\"n\":0.10000000000000001}}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":{\"n\":0.10000000000000001}}}\n" }, "accept": 1, "expected": { @@ -597,12 +597,12 @@ "method": "m", "payload_number_raw": "0.10000000000000001" }, - "sha256": "68818b6f110d2cccd6a651824a5b68d7de56277258e27af2c81aff64f38db440" + "sha256": "357725e455b5521fd0cfba53e353e3e79281cab32161adcef4a1bce3b84eeeb9" }, { "name": "cr_whitespace", "source": { - "ascii": "{\r \"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\r\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\r \"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\r\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "accept": 1, "expected": { @@ -614,54 +614,54 @@ "message_type": "notification", "method": "m" }, - "sha256": "435d380344625a41c0003bedb8c3bacaf2bd4f781229e418ee971cc366689df0" + "sha256": "5ea526e05e508ee0842908b1b4e817085a4c4ec9748a76903519bbc678a2746a" }, { "name": "unknown_request_field", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"status\":\"success\"}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null,\"status\":\"success\"}}\n" }, "error": "unknown_field", - "sha256": "21329b3124a5185ec017e70af508e215f92163dc16ded2c9692533fa56c78979" + "sha256": "770a2acf1f6c773c32e22ece420e963e2f6f70da3eae6226bc89d7a25581c967" }, { "name": "unknown_terminal_field", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"terminal\",\"status\":\"success\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"message\":{\"type\":\"terminal\",\"status\":\"success\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "unknown_field", - "sha256": "fecf99b88cf886008a296d97e9ee05ec52c607e2ce8d474f8d5bee04e6671d7d" + "sha256": "59ff77ee761ec3291dc44a2cc1ddad178f90b190b06fed9ca17896b0c33c982e" }, { "name": "duplicate_scope_generation", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"scope_generation\":3,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"scope_generation\":3,\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "aa6440e1d40fab97213f7610d2b15a6f60714ed8390cc9367a5be187e6064eb5" + "sha256": "7d335760ddb05557c1f58e042089a33ecc8479761438e253d9d69a7d3f498cf6" }, { "name": "duplicate_plugin_id", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"plugin_id\":\"p\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"plugin_id\":\"p\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "d9aea540436b94e04044267aad4dbe5ed52516e992e306e91fd07e05970724a4" + "sha256": "99ccc88aff4d15c1ec254ad3c84413985e4e7b770aaefbe2a6327a95c1d2bb93" }, { "name": "duplicate_scope_id", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"scope_id\":\"s\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"p\",\"scope_id\":\"s\",\"scope_generation\":3,\"call_id\":\"c\",\"scope_id\":\"s\",\"message\":{\"type\":\"notification\",\"method\":\"m\",\"payload\":null}}\n" }, "error": "duplicate_field", - "sha256": "306fe7fb1cc738803252fe0b9ddea293da1330934bcd2dae1519c9412947a3bf" + "sha256": "6f49e857d60e2d27d8376478302520589221511c4eb835d48b67c49a2f0f52a6" }, { "name": "chunk_null_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":null}}\n" }, - "sha256": "2c6f750ded4827318a2d4ae2d79c14eb55e1d7db6458a2e0c6a3bd9489335d2c", + "sha256": "5b8069b3abe368ec3c8cfc633ef39a3cfd8c112c06460aa434b559bee5681b12", "accept": 1, "expected": { "host_epoch": 7, @@ -675,9 +675,9 @@ { "name": "chunk_object_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":{\"delta\":\"hi\"}}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":{\"delta\":\"hi\"}}}\n" }, - "sha256": "fc073fea5a3c37774f5fa08bfa0b9780d1c1d688720603fdf2add0463fe0e8b3", + "sha256": "1582da27daea3f13486203f5f6832f63fae76018648a50d6351256e5a455ecc2", "accept": 1, "expected": { "host_epoch": 7, @@ -691,49 +691,49 @@ { "name": "chunk_with_method", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"method\":\"llm/stream\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"method\":\"llm/stream\",\"payload\":null}}\n" }, - "sha256": "a8a7a793d12e24a3a609bf6860c42cfa3dd7bb68936428486181d21eb67b98a1", + "sha256": "f5d7b3b589381192636700d28726fffed7b088f52ba6ac5ab2a9f49fd2823658", "error": "unknown_field" }, { "name": "chunk_with_status", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"status\":\"success\",\"payload\":null}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"status\":\"success\",\"payload\":null}}\n" }, - "sha256": "8274f9043ad1b12d4ff3395c7249c77b4adead740421289c340a2bed404a2bd3", + "sha256": "2b49d1c40a6515a837793c371c3d965b1013c5fb0bc86c686a72533e13230681", "error": "unknown_field" }, { "name": "chunk_missing_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\"}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\"}}\n" }, - "sha256": "c8be5b2c9c74c1d9b4a9db7542d61ded10ef6789509aaaa73096971168b803f4", + "sha256": "06d54b5eec30540adcbb5dc2ff0b930993156bcb259557f45bd33db472d738e5", "error": "wrong_shape" }, { "name": "terminal_missing_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"terminal\",\"status\":\"success\"}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"terminal\",\"status\":\"success\"}}\n" }, - "sha256": "bd93862ee9e496ebb6e806628c10caae963bed6587adf51c7c330489a2da4c32", + "sha256": "0131ffd015a50b0297fa57a71be0ec74db9bb6829a535db904c98c1434a7445b", "error": "wrong_shape" }, { "name": "request_missing_payload", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"request\",\"method\":\"opaque\"}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"request\",\"method\":\"opaque\"}}\n" }, - "sha256": "bcf570bdcb65075711c1fdc3c5c371b3d4d4aa344e5c08d6e0d36651f0492cb2", + "sha256": "a842c7bd15457d4418b84c6f12ab4993b58c71ca46d4346b0402c61fe20074ee", "error": "wrong_shape" }, { "name": "chunk_unknown_field", "source": { - "ascii": "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":null,\"bogus\":1}}\n" + "ascii": "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"scope-1\",\"scope_generation\":3,\"call_id\":\"stream\",\"message\":{\"type\":\"chunk\",\"payload\":null,\"bogus\":1}}\n" }, - "sha256": "fffc690418090d988a02dc3353a4f3fcb34e2ceb0d783476941fd2342b21aaac", + "sha256": "6ad3372e210d7301a272157f56748322155c14b0654bb270055140d177aa8ba9", "error": "unknown_field" } ] diff --git a/crates/rebon-plugin-protocol/tests/fixtures/v1/methods.json b/crates/rebon-plugin-protocol/tests/fixtures/v1/methods.json index c7f72a8..092fb90 100644 --- a/crates/rebon-plugin-protocol/tests/fixtures/v1/methods.json +++ b/crates/rebon-plugin-protocol/tests/fixtures/v1/methods.json @@ -2,10 +2,26 @@ "version": 1, "note": "Shared method-payload corpus. Rust reads it from tests/methods_corpus.rs and Node from test/methods.test.mjs; both must reach the same verdict and, on refusal, the same bracketed code. Validation order inside one payload is part of the contract, because it decides which code a payload with two problems reports.", "cases": [ + { "name": "load_missing_adapter", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs" }, "code": "[MISSING_FIELD]" }, + { "name": "load_adapter_null", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": null }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_array", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": [] }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_id_type", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": 1, "revision": 1 } }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_missing_id", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "revision": 1 } }, "code": "[MISSING_FIELD]" }, + { "name": "load_adapter_missing_revision", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native" } }, "code": "[MISSING_FIELD]" }, + { "name": "load_adapter_revision_string", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native", "revision": "1" } }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_revision_negative", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native", "revision": -1 } }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_revision_fraction", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native", "revision": 1.5 } }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_revision_overflow", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native", "revision": 4294967296 } }, "code": "[WRONG_SHAPE]" }, + { "name": "load_adapter_unknown_field", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "native", "revision": 1, "extra": true } }, "code": "[UNKNOWN_FIELD]" }, + { "name": "load_adapter_future_is_wire_data", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "future", "revision": 4294967295 } }, "accept": true }, + { "name": "load_adapter_cordis", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "cordis", "revision": 1 } }, "accept": true }, + { "name": "load_adapter_mods", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "claude-mods", "revision": 1 } }, "accept": true }, + { "name": "load_adapter_legacy", "kind": "plugin_load", "payload": { "pluginId": "p", "root": "/pkg", "entry": "i.mjs", "adapter": { "id": "legacy-1.9", "revision": 1 } }, "accept": true }, { "name": "load_minimal", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs" @@ -16,6 +32,7 @@ "name": "load_full", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -56,6 +73,7 @@ "name": "load_unknown_field", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -67,6 +85,7 @@ "name": "load_missing_entry", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a" }, "code": "[MISSING_FIELD]" @@ -75,6 +94,7 @@ "name": "load_reserved_plugin_id", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "$rebon/platform", "root": "/packages/demo", "entry": "index.mjs" @@ -85,6 +105,7 @@ "name": "load_empty_plugin_id", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "", "root": "/packages/demo", "entry": "index.mjs" @@ -95,6 +116,7 @@ "name": "load_control_character_entry", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index\u0001.mjs" @@ -105,6 +127,7 @@ "name": "load_duplicate_service", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -119,6 +142,7 @@ "name": "load_services_not_an_array", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -130,6 +154,7 @@ "name": "load_plugin_id_not_a_string", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": 1, "root": "/packages/demo", "entry": "index.mjs" @@ -140,6 +165,7 @@ "name": "load_plugin_id_too_long", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "root": "/packages/demo", "entry": "index.mjs" @@ -150,6 +176,7 @@ "name": "load_reports_the_plugin_id_before_the_entry", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "", "root": "/packages/demo", "entry": "" @@ -161,6 +188,7 @@ "note": "43 characters, 129 UTF-8 bytes. A host measuring UTF-16 units would accept this and disagree with the other one.", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一", "root": "/packages/demo", "entry": "index.mjs" @@ -171,6 +199,7 @@ "name": "load_multibyte_plugin_id_within_the_byte_bound", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一一", "root": "/packages/demo", "entry": "index.mjs" @@ -181,6 +210,7 @@ "name": "load_relative_root", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "packages/demo", "entry": "index.mjs" @@ -192,6 +222,7 @@ "note": "A supervisor and its host always run on the same machine, so both spellings count as absolute regardless of which platform reads this corpus.", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "C:/packages/demo", "entry": "index.mjs" @@ -202,6 +233,7 @@ "name": "load_absolute_entry", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "/etc/passwd" @@ -212,6 +244,7 @@ "name": "load_entry_climbs_out", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "src/../../index.mjs" @@ -223,6 +256,7 @@ "note": "A backslash separates on Windows only, so this path would mean two different things on two machines.", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "src\\index.mjs" @@ -233,6 +267,7 @@ "name": "load_nested_entry", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "dist/src/index.mjs" @@ -423,6 +458,7 @@ "name": "load_tools_absent_is_empty", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -434,6 +470,7 @@ "name": "load_tool_name_empty", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -447,6 +484,7 @@ "name": "load_tool_declared_twice", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -526,6 +564,7 @@ "name": "load_provider_declared_twice", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -625,6 +664,7 @@ "name": "load_provided_tools", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -757,6 +797,7 @@ "name": "load_seat_declared_twice", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -853,6 +894,7 @@ "name": "load_published_topic_declared_twice", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -867,6 +909,7 @@ "name": "load_published_topics_absent_is_empty", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs" @@ -877,6 +920,7 @@ "name": "load_published_topic_control_character", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -960,6 +1004,7 @@ "name": "load_config_absent_is_null", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs" @@ -970,6 +1015,7 @@ "name": "load_config_may_be_any_json", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", @@ -1106,6 +1152,7 @@ "name": "load_declares_commands", "kind": "plugin_load", "payload": { + "adapter": { "id": "native", "revision": 1 }, "pluginId": "plugin.a", "root": "/packages/demo", "entry": "index.mjs", diff --git a/crates/rebon-plugin-protocol/tests/no_arbitrary_precision.rs b/crates/rebon-plugin-protocol/tests/no_arbitrary_precision.rs index b58885f..7d2d59d 100644 --- a/crates/rebon-plugin-protocol/tests/no_arbitrary_precision.rs +++ b/crates/rebon-plugin-protocol/tests/no_arbitrary_precision.rs @@ -79,7 +79,7 @@ fn payload_tokens_survive_without_the_feature() { use rebon_plugin_protocol::{NdjsonCodec, WireMessage}; let frame = concat!( - r#"{"protocol_version":1,"host_epoch":7,"plugin_id":"p","scope_id":"s","#, + r#"{"protocol_version":2,"host_epoch":7,"plugin_id":"p","scope_id":"s","#, r#""scope_generation":3,"call_id":"c","message":{"type":"notification","#, r#""method":"m","payload":{"n":0.10000000000000001}}}"#, "\n" diff --git a/crates/rebon-plugin-protocol/tests/protocol.rs b/crates/rebon-plugin-protocol/tests/protocol.rs index e5d063c..e78cb19 100644 --- a/crates/rebon-plugin-protocol/tests/protocol.rs +++ b/crates/rebon-plugin-protocol/tests/protocol.rs @@ -163,7 +163,7 @@ fn codec_rejects_oversize_input_before_unbounded_growth_and_incomplete_eof() { )); let mut codec = NdjsonCodec::new(1024); - codec.push(br#"{"protocol_version":1}"#).unwrap(); + codec.push(br#"{"protocol_version":2}"#).unwrap(); assert!(matches!( codec.finish(), Err(CodecError::IncompleteFrame { .. }) @@ -177,27 +177,27 @@ fn public_serde_rejects_unsupported_version_on_encode_and_decode() { let encode_error = serde_json::to_vec(&unsupported).unwrap_err(); assert!(encode_error .to_string() - .contains("unsupported protocol version 2; expected 1")); + .contains("unsupported protocol version 3; expected 2")); let text = serde_json::to_string(&request("direct-serde-decode")) .unwrap() - .replace("\"protocol_version\":1", "\"protocol_version\":2"); + .replace("\"protocol_version\":2", "\"protocol_version\":3"); let decode_error = serde_json::from_str::(&text).unwrap_err(); assert!(decode_error .to_string() - .contains("unsupported protocol version 2; expected 1")); + .contains("unsupported protocol version 3; expected 2")); } #[test] fn codec_rejects_unsupported_version_and_unknown_fields() { let mut value = serde_json::to_value(request("bad-version")).unwrap(); - value["protocol_version"] = json!(2); + value["protocol_version"] = json!(1); let mut bytes = serde_json::to_vec(&value).unwrap(); bytes.push(b'\n'); let mut codec = NdjsonCodec::new(1024); assert!(matches!( codec.push(&bytes), - Err(CodecError::UnsupportedVersion { actual: 2, .. }) + Err(CodecError::UnsupportedVersion { actual: 1, .. }) )); assert!(matches!(codec.push(b"{}\n"), Err(CodecError::Failed))); @@ -604,7 +604,7 @@ fn platform_control_identity_is_reserved_exact_and_roundtrips() { payload: json!(null).into(), }, ); - let exact = "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"$rebon/platform\",\"scope_id\":\"$rebon/control\",\"scope_generation\":0,\"call_id\":\"control-1\",\"message\":{\"type\":\"request\",\"method\":\"platform/initialize\",\"payload\":null}}\n"; + let exact = "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"$rebon/platform\",\"scope_id\":\"$rebon/control\",\"scope_generation\":0,\"call_id\":\"control-1\",\"message\":{\"type\":\"request\",\"method\":\"platform/initialize\",\"payload\":null}}\n"; let encoded = NdjsonCodec::new(1024).encode(&envelope).unwrap(); assert_eq!(encoded, exact.as_bytes()); assert_eq!( @@ -618,7 +618,7 @@ fn cancel_shape_is_exact_and_intent_is_idempotent_without_terminal_ownership() { assert_eq!(CALL_CANCEL_METHOD, "call/cancel"); let cancel = WireEnvelope::cancel(identity("cancel-target")); assert!(cancel.is_cancel()); - let exact = "{\"protocol_version\":1,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"session-1\",\"scope_generation\":3,\"call_id\":\"cancel-target\",\"message\":{\"type\":\"notification\",\"method\":\"call/cancel\",\"payload\":null}}\n"; + let exact = "{\"protocol_version\":2,\"host_epoch\":7,\"plugin_id\":\"plugin.example\",\"scope_id\":\"session-1\",\"scope_generation\":3,\"call_id\":\"cancel-target\",\"message\":{\"type\":\"notification\",\"method\":\"call/cancel\",\"payload\":null}}\n"; assert_eq!( NdjsonCodec::new(1024).encode(&cancel).unwrap(), exact.as_bytes() diff --git a/crates/rebon-plugin-protocol/tests/registry.rs b/crates/rebon-plugin-protocol/tests/registry.rs index 3391fbb..62be79f 100644 --- a/crates/rebon-plugin-protocol/tests/registry.rs +++ b/crates/rebon-plugin-protocol/tests/registry.rs @@ -13,6 +13,10 @@ fn load(plugin: &str) -> PluginLoadRequest { plugin_id: plugin.into(), root: "/packages/demo".into(), entry: "index.mjs".into(), + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, services: vec!["compose".into(), "render".into()], event_topics: vec!["session".into()], published_topics: vec!["compose:session/append".into()], diff --git a/crates/rebon-plugin-supervisor/src/state.rs b/crates/rebon-plugin-supervisor/src/state.rs index db3ab0c..76eb083 100644 --- a/crates/rebon-plugin-supervisor/src/state.rs +++ b/crates/rebon-plugin-supervisor/src/state.rs @@ -611,6 +611,10 @@ mod tests { let mut state = SupervisorState::new(7).unwrap(); for plugin in ["plugin.a", "plugin.b"] { let request = PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: plugin.into(), root: "/pkg".into(), entry: "index.mjs".into(), diff --git a/crates/rebon-plugin-supervisor/tests/await_drain.rs b/crates/rebon-plugin-supervisor/tests/await_drain.rs index 6ace098..88968af 100644 --- a/crates/rebon-plugin-supervisor/tests/await_drain.rs +++ b/crates/rebon-plugin-supervisor/tests/await_drain.rs @@ -47,6 +47,10 @@ fn package(dir: &std::path::Path) -> String { fn load_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: ID.into(), root, entry: "index.mjs".into(), diff --git a/crates/rebon-plugin-supervisor/tests/host_process.rs b/crates/rebon-plugin-supervisor/tests/host_process.rs index fa4a1f4..04b9614 100644 --- a/crates/rebon-plugin-supervisor/tests/host_process.rs +++ b/crates/rebon-plugin-supervisor/tests/host_process.rs @@ -248,7 +248,7 @@ process.stdin.on('data', (chunk) => {{ const frame = JSON.parse(line); if (frame.message.type === 'request' && frame.message.method === 'platform/initialize') {{ process.stdout.write(JSON.stringify({{ ...frame, message: {{ type: 'terminal', status: 'success', payload: null }} }}) + NL); - process.stdout.write(JSON.stringify({{ protocol_version: 1, host_epoch: frame.host_epoch, + process.stdout.write(JSON.stringify({{ protocol_version: frame.protocol_version, host_epoch: frame.host_epoch, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id: 'up-1', message: {{ type: 'request', method: '{method}', payload: {payload} }} }}) + NL); }} else if (frame.message.type === 'terminal' && frame.call_id === 'up-1') {{ diff --git a/crates/rebon-plugin-supervisor/tests/plugin_lifecycle.rs b/crates/rebon-plugin-supervisor/tests/plugin_lifecycle.rs index 430c5ae..83ba5b8 100644 --- a/crates/rebon-plugin-supervisor/tests/plugin_lifecycle.rs +++ b/crates/rebon-plugin-supervisor/tests/plugin_lifecycle.rs @@ -58,6 +58,10 @@ export function activate(plugin) { fn load_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -75,6 +79,10 @@ fn load_request(root: String) -> PluginLoadRequest { fn listener_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -448,6 +456,10 @@ export function activate(plugin) { fn tool_request(root: String, tools: Vec) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -638,6 +650,10 @@ export function activate(plugin) { fn streaming_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -844,6 +860,10 @@ export function activate(plugin) { fn llm_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -1034,6 +1054,10 @@ export function activate(plugin) { fn provider_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -1264,6 +1288,10 @@ impl SeatDispatcher for RecordingSeats { fn seat_request(root: String, seats: Vec) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -1516,6 +1544,10 @@ impl EventPublisher for RecordingEvents { fn emit_request(root: String, published_topics: Vec) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), @@ -1750,6 +1782,10 @@ export function activate(plugin) { fn stalling_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), diff --git a/crates/rebon-plugin-supervisor/tests/unload_reload.rs b/crates/rebon-plugin-supervisor/tests/unload_reload.rs index 7598c59..02dece1 100644 --- a/crates/rebon-plugin-supervisor/tests/unload_reload.rs +++ b/crates/rebon-plugin-supervisor/tests/unload_reload.rs @@ -43,6 +43,10 @@ fn plugin_package(dir: &std::path::Path) -> String { fn load_request(root: String) -> PluginLoadRequest { PluginLoadRequest { + adapter: rebon_plugin_protocol::PluginAdapter { + id: "native".into(), + revision: 1, + }, plugin_id: "plugin.demo".into(), root, entry: "index.mjs".into(), diff --git a/crates/rebon-session-runtime/src/commands/plugin.rs b/crates/rebon-session-runtime/src/commands/plugin.rs index 34b4961..4b10b7f 100644 --- a/crates/rebon-session-runtime/src/commands/plugin.rs +++ b/crates/rebon-session-runtime/src/commands/plugin.rs @@ -548,7 +548,7 @@ mod tests { std::fs::create_dir_all(market.join("radar/hooks")).unwrap(); std::fs::write( market.join("radar/.claude-plugin/plugin.json"), - r#"{"name":"radar"}"#, + r#"{"name":"radar","rebon":{"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]}}"#, ) .unwrap(); std::fs::write( @@ -609,7 +609,7 @@ mod tests { std::fs::create_dir(&dir).unwrap(); std::fs::write( dir.join("rebon-plugin.json"), - r#"{"name":"demo","version":"1.0.0"}"#, + r#"{"name":"demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}}"#, ) .unwrap(); } diff --git a/crates/rebon-session-runtime/src/plugin/dsh_npm.rs b/crates/rebon-session-runtime/src/plugin/dsh_npm.rs index ae7a08f..1ed5423 100644 --- a/crates/rebon-session-runtime/src/plugin/dsh_npm.rs +++ b/crates/rebon-session-runtime/src/plugin/dsh_npm.rs @@ -29,6 +29,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{anyhow, bail, Context}; +use rebon_harness::rebon_plugin_package::compatibility::{ + host_sdk_versions, npm_sdk_requirements, CompatibilityDeclaration, PluginFormat, + ADAPTER_REVISION, FORMAT_VERSION, +}; use rebon_harness::rebon_plugin_package::container::ContainerRequest; use serde_json::{json, Value}; @@ -319,6 +323,11 @@ pub fn skills_manifest(package: &Value, roots: &[String]) -> anyhow::Result { + package["peerDependencies"]["@deepseek-ai/dsh-session"] = json!("*") + } + "incompatible-sdk" => package["peerDependencies"]["cordis"] = json!("^4.0.0"), + "format" | "adapter" => { + package["rebon"] = + json!({"format":"dsh-npm","formatVersion":1,"adapterRevision":1,"sdk":[]}); + package["rebon"][if case == "format" { + "formatVersion" + } else { + "adapterRevision" + }] = json!(2); + } + "missing-sdk" => { + package.as_object_mut().unwrap().remove("peerDependencies"); + } + _ => unreachable!(), + } + let original = serde_json::to_vec(&package).unwrap(); + std::fs::write(dir.path().join("package.json"), &original).unwrap(); + let error = adapt_with(dir.path(), None, &dir.path().join("missing-node"), &runtime) + .unwrap_err(); + let structured = error + .downcast_ref::() + .expect("compatibility is checked before starting Node or npm"); + assert!(matches!( + (case, structured), + ( + "unknown-sdk" | "incompatible-sdk", + CompatibilityError::UnsupportedSdk { .. } + ) | ("format", CompatibilityError::UnsupportedFormat { .. }) + | ("adapter", CompatibilityError::UnsupportedAdapter { .. }) + | ( + "missing-sdk", + CompatibilityError::MissingCompatibility { .. } + ) + )); + assert_eq!( + std::fs::read(dir.path().join("package.json")).unwrap(), + original + ); + assert!(!dir.path().join("rebon-plugin.json").exists()); + assert!(!dir.path().join("node_modules").exists()); + } + } + + #[test] + fn a_cordis_package_preserves_sdk_evidence_after_the_probe() { + let _home = rebon_tool::tasks::test_support::TestConfigHome::new("dsh-compatibility-probe"); + let Some(node) = std::env::var_os("REBON_TEST_NODE").map(PathBuf::from) else { + assert!( + std::env::var_os("REBON_REQUIRE_TEST_NODE").is_none(), + "REBON_TEST_NODE must be set" + ); + eprintln!("skipping: set REBON_TEST_NODE"); + return; + }; + let dir = tempfile::tempdir().unwrap(); + let runtime = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../runtimes/node/compose-runtime"); + std::fs::write(dir.path().join("package.json"), json!({ + "name":"@deepseek-ai/dsh-demo", "version":"1.0.0", "type":"module", "main":"index.mjs", + "peerDependencies":{"cordis":"^4"} + }).to_string()).unwrap(); + std::fs::write( + dir.path().join("index.mjs"), + "export function apply(ctx) {}\n", + ) + .unwrap(); + let adapted = adapt_with(dir.path(), None, &node, &runtime).unwrap(); + assert_eq!(adapted.plugin_id, "dsh-demo"); + let slim: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("package.json")).unwrap()) + .unwrap(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(dir.path().join("rebon-plugin.json")).unwrap()) + .unwrap(); + assert!(slim.get("peerDependencies").is_none()); + assert_eq!(slim["rebon"], manifest["compatibility"]); + assert_eq!( + manifest["compatibility"]["sdk"], + json!([{"name":"cordis","range":"^4"}]) + ); + let parsed = + rebon_harness::rebon_plugin_package::PluginManifest::load_from_dir(dir.path()).unwrap(); + assert_eq!( + rebon_harness::rebon_plugin_package::compatibility::resolve_package( + dir.path(), + &parsed, + None + ) + .unwrap(), + rebon_harness::rebon_plugin_package::compatibility::LoadCompatibility::Current( + PluginFormat::DshNpm + ) + ); + } + + #[test] + fn static_skills_use_the_material_format_without_starting_node() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("skills/demo")).unwrap(); + std::fs::write(dir.path().join("skills/demo/SKILL.md"), "demo").unwrap(); + std::fs::write( + dir.path().join("package.json"), + json!({"name":"@deepseek-ai/dsh-skills", "version":"1"}).to_string(), + ) + .unwrap(); + let absent = dir.path().join("missing"); + let adapted = adapt_with(dir.path(), None, &absent, &absent).unwrap(); + assert_eq!(adapted.skills, vec!["skills"]); + let parsed = + rebon_harness::rebon_plugin_package::PluginManifest::load_from_dir(dir.path()).unwrap(); + assert_eq!( + rebon_harness::rebon_plugin_package::compatibility::resolve_package( + dir.path(), + &parsed, + None + ) + .unwrap(), + rebon_harness::rebon_plugin_package::compatibility::LoadCompatibility::Current( + PluginFormat::Rebon + ) + ); + } + #[test] fn only_npm_cordis_packages_are_adapted() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/rebon-session-runtime/src/plugin/installer.rs b/crates/rebon-session-runtime/src/plugin/installer.rs index 53f0bb6..de34956 100644 --- a/crates/rebon-session-runtime/src/plugin/installer.rs +++ b/crates/rebon-session-runtime/src/plugin/installer.rs @@ -221,6 +221,11 @@ impl PluginInstaller { None => PluginSourceIdentity::local(Path::new(&source_label))?, }; let manifest = PluginManifest::load_from_dir(&staged_root)?; + rebon_harness::rebon_plugin_package::compatibility::resolve_package( + &staged_root, + &manifest, + None, + )?; let runtime_ids: Vec = manifest .capabilities .kernel_plugins @@ -255,7 +260,8 @@ impl PluginInstaller { } } ensure_self_contained(&staged_root, &manifest)?; - let digest = compute_dir_digest(&staged_root)?; + let digest = + rebon_harness::rebon_plugin_package::integrity::compute_dir_digest(&staged_root)?; Ok((manifest, digest, source_identity)) })(); let (staged_manifest, digest, source_identity) = match admitted { @@ -485,7 +491,10 @@ impl PluginInstaller { (_, false) => VerificationOutcome::Missing { path: dir }, (None, true) => VerificationOutcome::NoRecordedDigest, (Some(recorded), true) => { - let actual = compute_dir_digest(&dir)?; + let actual = + rebon_harness::rebon_plugin_package::integrity::compute_dir_digest( + &dir, + )?; if actual == *recorded { VerificationOutcome::Match } else { @@ -662,8 +671,7 @@ fn ensure_self_contained( ); } - let mut found = Vec::new(); - collect_files(root, root, &mut found)?; + let found = rebon_harness::rebon_plugin_package::integrity::package_files(root)?; let native: Vec = found .into_iter() .map(|(relative, _)| relative) @@ -709,47 +717,6 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> { Ok(()) } -fn compute_dir_digest(root: &Path) -> anyhow::Result { - let mut files = Vec::new(); - collect_files(root, root, &mut files)?; - files.sort_by(|a, b| a.0.cmp(&b.0)); - let mut hasher = Sha256::new(); - for (relative, path) in files { - hasher.update(relative.as_bytes()); - hasher.update([0]); - let mut file = fs::File::open(&path)?; - let mut buf = [0u8; 8192]; - loop { - let n = file.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - hasher.update([0]); - } - Ok(format!("sha256:{}", to_hex(&hasher.finalize()))) -} - -fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> anyhow::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - if metadata.is_dir() { - collect_files(root, &path, out)?; - } else if metadata.is_file() { - let relative = path - .strip_prefix(root) - .unwrap_or(&path) - .to_string_lossy() - .replace('\\', "/"); - out.push((relative, path)); - } - } - Ok(()) -} - fn to_hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); @@ -834,7 +801,7 @@ mod tests { std::fs::create_dir_all(package.join("skills/demo")).unwrap(); std::fs::write( package.join("rebon-plugin.json"), - r#"{"name":"demo","version":"1.0.0","capabilities":{"skills":["skills/demo"]}}"#, + r#"{"name":"demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]},"capabilities":{"skills":["skills/demo"]}}"#, ) .unwrap(); std::fs::write(package.join("skills/demo/SKILL.md"), "demo").unwrap(); @@ -861,12 +828,12 @@ mod tests { std::fs::create_dir_all(package.join("skills/demo")).unwrap(); std::fs::write(package.join("skills/demo/SKILL.md"), "demo").unwrap(); let write_manifest = |version: &str| { - std::fs::write( - package.join("rebon-plugin.json"), - format!( - r#"{{"name":"demo","version":"{version}","capabilities":{{"skills":["skills/demo"]}}}}"# - ), - ) + std::fs::write(package.join("rebon-plugin.json"), { + let mut manifest: serde_json::Value = + serde_json::from_slice(DEMO_MANIFEST).unwrap(); + manifest["version"] = serde_json::json!(version); + serde_json::to_vec(&manifest).unwrap() + }) .unwrap(); }; let store = PluginStore::new(tmp.path().join("home"), cwd.clone()); @@ -910,7 +877,7 @@ mod tests { std::fs::create_dir_all(&package).unwrap(); std::fs::write( package.join("rebon-plugin.json"), - r#"{"name":"visual-demo","version":"1.0.0","capabilities":{"appVisualEffects":[{"id":"black-hole","surface":"chatEmpty","kind":"blackHole"}]}}"#, + r#"{"name":"visual-demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]},"capabilities":{"appVisualEffects":[{"id":"black-hole","surface":"chatEmpty","kind":"blackHole"}]}}"#, ) .unwrap(); let store = PluginStore::new(tmp.path().join("home"), cwd.clone()); @@ -959,10 +926,10 @@ mod tests { /// Declares a skill, so it may only be used by an archive that ships one: /// manifest loading checks that declared asset paths exist. const DEMO_MANIFEST: &[u8] = - br#"{"name":"demo","version":"1.0.0","capabilities":{"skills":["skills/demo"]}}"#; + br#"{"name":"demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]},"capabilities":{"skills":["skills/demo"]}}"#; /// The same plugin with nothing declared, for archives whose payload is not /// what the test is about. - const PLAIN_MANIFEST: &[u8] = br#"{"name":"demo","version":"1.0.0"}"#; + const PLAIN_MANIFEST: &[u8] = br#"{"name":"demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}}"#; #[test] fn installs_from_a_package_archive() { @@ -1134,7 +1101,7 @@ mod tests { fn a_declared_native_module_is_refused_as_not_yet_supported() { let tmp = tempfile::tempdir().unwrap(); let _guard = EnvGuard::set_config_dir(&tmp.path().join("home")); - let manifest = br#"{"name":"demo","version":"1.0.0","requirements":{"nativeModules":["build/Release/fast.node"]}}"#; + let manifest = br#"{"name":"demo","version":"1.0.0","compatibility":{"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]},"requirements":{"nativeModules":["build/Release/fast.node"]}}"#; let (archive, _store, installer) = archive_fixture( tmp.path(), &[ @@ -1277,6 +1244,64 @@ mod tests { ); } + #[test] + fn compatibility_rejection_preserves_the_existing_install() { + use rebon_harness::rebon_plugin_package::{ + compatibility::CompatibilityError, integrity::compute_dir_digest, + }; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + let _guard = EnvGuard::set_config_dir(&home); + let (archive, store, installer) = + archive_fixture(tmp.path(), &[("rebon-plugin.json", PLAIN_MANIFEST)]); + installer + .install(archive.to_str().unwrap(), PluginScope::User, None) + .unwrap(); + let data = + rebon_harness::rebon_plugin_package::container::container_data_dir(&home, "demo"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::write(data.join("state.json"), b"retained").unwrap(); + rebon_harness::rebon_plugin_package::container::ContainerGrants::default() + .save(&home) + .unwrap(); + let before = compute_dir_digest(&home).unwrap(); + let initial = store.load_state(PluginScope::User).unwrap(); + for case in ["missing", "format", "adapter", "sdk"] { + let mut manifest: serde_json::Value = serde_json::from_slice(PLAIN_MANIFEST).unwrap(); + manifest["version"] = serde_json::json!("1.1.0"); + match case { + "missing" => { + manifest.as_object_mut().unwrap().remove("compatibility"); + } + "format" => manifest["compatibility"]["formatVersion"] = serde_json::json!(2), + "adapter" => manifest["compatibility"]["adapterRevision"] = serde_json::json!(2), + "sdk" => manifest["compatibility"]["sdk"][0]["range"] = serde_json::json!("^2"), + _ => unreachable!(), + } + let bytes = serde_json::to_vec(&manifest).unwrap(); + std::fs::write( + &archive, + super::super::package::tar_archive(&[("rebon-plugin.json", &bytes)]), + ) + .unwrap(); + let error = installer + .install(archive.to_str().unwrap(), PluginScope::User, None) + .unwrap_err(); + let structured = error + .downcast_ref::() + .expect("typed compatibility refusal"); + assert!(matches!( + (case, structured), + ("missing", CompatibilityError::MissingCompatibility { .. }) + | ("format", CompatibilityError::UnsupportedFormat { .. }) + | ("adapter", CompatibilityError::UnsupportedAdapter { .. }) + | ("sdk", CompatibilityError::UnsupportedSdk { .. }) + )); + assert_eq!(store.load_state(PluginScope::User).unwrap(), initial); + assert_eq!(compute_dir_digest(&home).unwrap(), before, "{case}"); + } + } + #[test] fn project_install_requires_trust() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/rebon-session-runtime/src/plugin/marketplace.rs b/crates/rebon-session-runtime/src/plugin/marketplace.rs index b3a7fb3..cf2ba21 100644 --- a/crates/rebon-session-runtime/src/plugin/marketplace.rs +++ b/crates/rebon-session-runtime/src/plugin/marketplace.rs @@ -450,8 +450,13 @@ impl MarketplaceManager { .config_home .join(rebon_types::MODS_DIR) .join(&entry.name); - let mod_ = rebon_harness::rebon_plugin_package::read_claude_mod(&folder) + let mut mod_ = rebon_harness::rebon_plugin_package::read_claude_mod(&folder) .map_err(anyhow::Error::msg)?; + mod_.declare_install_compatibility(); + let manifest = rebon_harness::rebon_plugin_package::plugin_manifest_for(&mod_); + rebon_harness::rebon_plugin_package::compatibility::resolve_package( + &folder, &manifest, None, + )?; let runtime_ids = vec![mod_.manifest.name]; let superseded = installs.ensure_source_available( &source_identity, @@ -481,7 +486,16 @@ impl MarketplaceManager { target.display() ); } - replace_dir(&folder, &target)?; + let staged = stage_dir(&folder, &target)?; + let mod_compatibility = + rebon_harness::rebon_plugin_package::marketplace::ModCompatibilityRecord { + manifest, + first_seen: false, + digest: rebon_harness::rebon_plugin_package::integrity::compute_dir_digest( + &staged, + )?, + }; + publish_staged_dir(&staged, &target)?; for old_id in superseded { installs.by_id.remove(&old_id); } @@ -505,6 +519,7 @@ impl MarketplaceManager { version: entry.version.clone(), kernel_plugins: runtime_ids, source_identity: Some(source_identity.clone()), + mod_compatibility: Some(mod_compatibility), installed_at_ms: rebon_types::wall_clock_ms(), // A mod declares nothing beyond its own folder. granted: Default::default(), @@ -550,6 +565,7 @@ impl MarketplaceManager { version: Some(record.version.clone()), kernel_plugins, source_identity: Some(source_identity), + mod_compatibility: None, installed_at_ms: rebon_types::wall_clock_ms(), granted: rebon_harness::rebon_plugin_package::container::ContainerGrants::load( &self.config_home, @@ -995,6 +1011,11 @@ fn http_get(url: &str) -> anyhow::Result> { /// Puts a copy of `src` at `dst`, replacing what is there, through a staged /// copy beside it so a failed copy leaves the old one. fn replace_dir(src: &Path, dst: &Path) -> anyhow::Result<()> { + let staged = stage_dir(src, dst)?; + publish_staged_dir(&staged, dst) +} + +fn stage_dir(src: &Path, dst: &Path) -> anyhow::Result { let parent = dst .parent() .ok_or_else(|| anyhow!("{} has no parent", dst.display()))?; @@ -1005,8 +1026,12 @@ fn replace_dir(src: &Path, dst: &Path) -> anyhow::Result<()> { rebon_types::wall_clock_ms() )); copy_dir_recursive(src, &stage)?; + Ok(stage) +} + +fn publish_staged_dir(stage: &Path, dst: &Path) -> anyhow::Result<()> { remove_dir_if_present(dst)?; - std::fs::rename(&stage, dst).with_context(|| format!("moving {} into place", dst.display()))?; + std::fs::rename(stage, dst).with_context(|| format!("moving {} into place", dst.display()))?; Ok(()) } @@ -1170,6 +1195,7 @@ mod tests { &json!({ "name": name, "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "capabilities": { "kernelPlugins": { name: { "entry": "plugin.mjs", "commands": ["hi"] } } } }) .to_string(), @@ -1183,6 +1209,7 @@ mod tests { &json!({ "name": name, "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "capabilities": { "kernelPlugins": { name: { "entry": "plugin.mjs" } } }, "metadata": { "kernelPluginConfig": { name: { "allowParallelInProgress": false } } } }) @@ -1200,6 +1227,7 @@ mod tests { &json!({ "name": "search", "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "capabilities": { "kernelPlugins": { "search": { "entry": "plugin.mjs" } } }, "container": { "network": ["api.exa.ai"], "env": ["EXA_API_KEY"] } }) @@ -1250,6 +1278,7 @@ mod tests { &json!({ "name": "search", "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "capabilities": { "kernelPlugins": { "search": { "entry": "plugin.mjs" } } }, "container": { "network": ["api.exa.ai"] } }) @@ -1835,6 +1864,171 @@ mod tests { assert!(!fx.home.join("mods/radar").exists()); } + #[test] + fn first_party_dsh_snapshot_packages_still_install() { + let _home = + rebon_tool::tasks::test_support::TestConfigHome::new("first-party-dsh-snapshot"); + let fx = fixture(); + let builtin = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../marketplace"); + let manager = manager(&fx).with_builtin(Some(builtin)); + for name in [ + "dsh-llm-deepseek", + "dsh-tool-todo", + "dsh-tool-web", + "dsh-web-search-exa", + ] { + let installed = manager + .install(&format!("{name}@rebon"), PluginScope::User) + .unwrap(); + assert_eq!(installed.kind, InstallKind::Package); + assert_eq!(installed.kernel_plugins, vec![name]); + } + let store = PluginStore::new(fx.home.clone(), fx.work.clone()); + let found = + rebon_harness::rebon_plugin_package::discovery::discover(&store, &[], &fx.work, true) + .unwrap(); + assert!(found.warnings.is_empty(), "{:?}", found.warnings); + assert_eq!(found.plugins.len(), 4); + assert!(found.plugins.iter().all(|plugin| plugin.compatibility + == rebon_harness::rebon_plugin_package::compatibility::LoadCompatibility::Current( + rebon_harness::rebon_plugin_package::compatibility::PluginFormat::DshNpm + ))); + } + + #[test] + fn an_upstream_mod_installs_without_rewriting_its_manifest() { + let _home = + rebon_tool::tasks::test_support::TestConfigHome::new("mod-synthesized-contract"); + let fx = fixture(); + let root = fx.work.join("market"); + marketplace(&root, "team"); + let source = root.join("mods/radar/.claude-plugin/plugin.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&source).unwrap()).unwrap(); + manifest.as_object_mut().unwrap().remove("rebon"); + let original = manifest.to_string(); + write(&source, &original); + write( + &root.join("mods/radar/.git/config"), + "excluded checkout metadata", + ); + write( + &root.join("mods/radar/.stage-old/leftover"), + "excluded staging files", + ); + let manager = manager(&fx); + manager.add("./market").unwrap(); + let installed = manager.install("radar@team", PluginScope::User).unwrap(); + assert_eq!( + std::fs::read_to_string(installed.location.join(".claude-plugin/plugin.json")).unwrap(), + original + ); + assert_eq!(std::fs::read_to_string(source).unwrap(), original); + let recorded = manager + .installs() + .unwrap() + .by_id + .remove("radar@team") + .unwrap(); + assert_eq!(recorded, installed); + let contract = recorded.mod_compatibility.as_ref().unwrap(); + let declaration = contract.manifest.compatibility.as_ref().unwrap(); + assert_eq!(declaration.format, "claude-mods"); + assert_eq!(declaration.adapter_revision, 1); + assert_eq!(declaration.sdk[0].range, "^1"); + let mod_ = + rebon_harness::rebon_plugin_package::read_claude_mod(&installed.location).unwrap(); + assert!(mod_.compatibility.is_none()); + assert_eq!( + rebon_harness::rebon_plugin_package::compatibility::resolve_mod(&mod_, &fx.home) + .unwrap(), + rebon_harness::rebon_plugin_package::compatibility::LoadCompatibility::Current( + rebon_harness::rebon_plugin_package::compatibility::PluginFormat::ClaudeMods + ) + ); + } + + #[test] + fn a_local_upstream_mod_persists_its_synthesized_contract_in_the_store() { + let _home = rebon_tool::tasks::test_support::TestConfigHome::new("local-mod-contract"); + let fx = fixture(); + let root = fx.work.join("mod"); + write_mod(&root, "radar"); + let installer = PluginInstaller::new( + PluginStore::new(fx.home.clone(), fx.work.clone()), + fx.work.clone(), + Vec::new(), + ); + let record = installer + .install(&root.to_string_lossy(), PluginScope::User, None) + .unwrap(); + let manifest = record.manifest.unwrap(); + assert_eq!( + manifest.compatibility.as_ref().unwrap().format, + "claude-mods" + ); + let discovered = rebon_harness::rebon_plugin_package::discovery::discover( + &PluginStore::new(fx.home.clone(), fx.work.clone()), + &[], + &fx.work, + true, + ) + .unwrap(); + assert!(discovered.warnings.is_empty(), "{:?}", discovered.warnings); + assert_eq!( + discovered.plugins[0].compatibility.adapter_id(), + "claude-mods" + ); + } + + #[test] + fn incompatible_mod_updates_preserve_records_data_and_grants() { + let _home = + rebon_tool::tasks::test_support::TestConfigHome::new("mod-compatibility-refusal"); + let fx = fixture(); + let root = fx.work.join("market"); + marketplace(&root, "team"); + let manager = manager(&fx); + manager.add("./market").unwrap(); + manager.install("radar@team", PluginScope::User).unwrap(); + let data = rebon_harness::rebon_plugin_package::container::container_data_dir( + &fx.home, + "mod-radar", + ); + write(&data.join("state.txt"), "retained"); + let before = + rebon_harness::rebon_plugin_package::integrity::compute_dir_digest(&fx.home).unwrap(); + let path = root.join("mods/radar/.claude-plugin/plugin.json"); + let original: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + for (case, message) in [ + ("malformed", "unsupported plugin format"), + ("format", "unsupported plugin format"), + ("sdk", "unsupported SDK"), + ("adapter", "unsupported adapter"), + ] { + let mut manifest = original.clone(); + manifest["rebon"] = json!({"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]}); + match case { + "malformed" => manifest["rebon"] = json!(null), + "format" => manifest["rebon"]["formatVersion"] = json!(2), + "sdk" => manifest["rebon"]["sdk"][0]["range"] = json!("^2"), + "adapter" => manifest["rebon"]["adapterRevision"] = json!(2), + _ => unreachable!(), + } + write(&path, &manifest.to_string()); + let error = manager + .install("radar@team", PluginScope::User) + .unwrap_err(); + assert!(error.to_string().contains(message), "{error}"); + assert_eq!( + rebon_harness::rebon_plugin_package::integrity::compute_dir_digest(&fx.home) + .unwrap(), + before + ); + } + } + #[test] fn builtin_marketplace_upgrade_survives_relocation() { let _home = rebon_tool::tasks::test_support::TestConfigHome::new("builtin-relocation"); diff --git a/crates/rebon-session-runtime/src/plugin/runtime.rs b/crates/rebon-session-runtime/src/plugin/runtime.rs index d7eaaab..8b16c79 100644 --- a/crates/rebon-session-runtime/src/plugin/runtime.rs +++ b/crates/rebon-session-runtime/src/plugin/runtime.rs @@ -274,6 +274,7 @@ mod tests { serde_json::json!({ "name": "fake-provider-plugin", "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "capabilities": { "modelProviders": { "fake-provider": { @@ -367,6 +368,7 @@ mod tests { serde_json::json!({ "name": "claudecode-plugin", "version": "1.0.0", + "compatibility": {"format":"rebon-plugin","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-plugin-api","range":"^1"}]}, "requirements": {"externalCommands": ["claude-agent-acp"]}, "capabilities": { "acpAgents": { diff --git a/crates/rebon-session-runtime/src/plugin/source.rs b/crates/rebon-session-runtime/src/plugin/source.rs index 581be74..03795b4 100644 --- a/crates/rebon-session-runtime/src/plugin/source.rs +++ b/crates/rebon-session-runtime/src/plugin/source.rs @@ -64,8 +64,9 @@ pub(crate) fn resolve_install_source( let root = candidate .canonicalize() .with_context(|| format!("failed to resolve mod folder {}", candidate.display()))?; - let mod_ = rebon_harness::rebon_plugin_package::read_claude_mod(&root) + let mut mod_ = rebon_harness::rebon_plugin_package::read_claude_mod(&root) .map_err(|reason| anyhow::anyhow!(reason))?; + mod_.declare_install_compatibility(); let manifest = rebon_harness::rebon_plugin_package::plugin_manifest_for(&mod_); manifest.validate(None)?; return Ok(ResolvedPluginSource::ClaudeMod { root, manifest }); diff --git a/marketplace/plugins/dsh-llm-deepseek/rebon-plugin.json b/marketplace/plugins/dsh-llm-deepseek/rebon-plugin.json index 25bbaa2..277906f 100644 --- a/marketplace/plugins/dsh-llm-deepseek/rebon-plugin.json +++ b/marketplace/plugins/dsh-llm-deepseek/rebon-plugin.json @@ -1,6 +1,13 @@ { "name": "dsh-llm-deepseek", "version": "0.1.0", + "compatibility": { + "format": "dsh-npm", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "@deepseek-ai/cordis", "range": "4.0.0-rc.8" }], + "dshSnapshot": "sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711" + }, "description": "DeepSeek Harness model provider for the official DeepSeek API, on the plugin plane. Packaged from the DeepSeek Harness module Rebon vendors (@deepseek-ai/dsh-llm-deepseek).", "license": "MIT", "capabilities": { diff --git a/marketplace/plugins/dsh-tool-todo/rebon-plugin.json b/marketplace/plugins/dsh-tool-todo/rebon-plugin.json index dc56bfe..99f93b3 100644 --- a/marketplace/plugins/dsh-tool-todo/rebon-plugin.json +++ b/marketplace/plugins/dsh-tool-todo/rebon-plugin.json @@ -1,6 +1,13 @@ { "name": "dsh-tool-todo", "version": "0.1.0", + "compatibility": { + "format": "dsh-npm", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "@deepseek-ai/cordis", "range": "4.0.0-rc.8" }], + "dshSnapshot": "sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711" + }, "description": "DeepSeek Harness todo list: a todo_write tool the model keeps its plan in. Packaged from the DeepSeek Harness module Rebon vendors (@deepseek-ai/dsh-tool-todo).", "license": "MIT", "capabilities": { diff --git a/marketplace/plugins/dsh-tool-web/rebon-plugin.json b/marketplace/plugins/dsh-tool-web/rebon-plugin.json index 24ab0ff..9fb408f 100644 --- a/marketplace/plugins/dsh-tool-web/rebon-plugin.json +++ b/marketplace/plugins/dsh-tool-web/rebon-plugin.json @@ -1,6 +1,13 @@ { "name": "dsh-tool-web", "version": "0.1.0", + "compatibility": { + "format": "dsh-npm", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "@deepseek-ai/cordis", "range": "4.0.0-rc.8" }], + "dshSnapshot": "sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711" + }, "description": "DeepSeek Harness web tools: web_search and web_fetch. Packaged from the DeepSeek Harness module Rebon vendors (@deepseek-ai/dsh-tool-web).", "license": "MIT", "capabilities": { diff --git a/marketplace/plugins/dsh-web-search-exa/rebon-plugin.json b/marketplace/plugins/dsh-web-search-exa/rebon-plugin.json index ea95595..7c7c27f 100644 --- a/marketplace/plugins/dsh-web-search-exa/rebon-plugin.json +++ b/marketplace/plugins/dsh-web-search-exa/rebon-plugin.json @@ -1,6 +1,13 @@ { "name": "dsh-web-search-exa", "version": "0.1.0", + "compatibility": { + "format": "dsh-npm", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "@deepseek-ai/cordis", "range": "4.0.0-rc.8" }], + "dshSnapshot": "sha256:baa83e6917f79e67c213a00a2882848baa195a86d185dfab65d31831bdce3711" + }, "description": "DeepSeek Harness Exa search backend for web_search (needs an Exa API key). Packaged from the DeepSeek Harness module Rebon vendors (@deepseek-ai/dsh-web-search-exa).", "license": "MIT", "capabilities": { diff --git a/runtimes/node/compose-runtime/src/loader.mjs b/runtimes/node/compose-runtime/src/loader.mjs index aef9a7b..cbd594f 100644 --- a/runtimes/node/compose-runtime/src/loader.mjs +++ b/runtimes/node/compose-runtime/src/loader.mjs @@ -7,20 +7,12 @@ // they differ only in what "loading" means, which is exactly the question the // host delegates. // -// The rule, in order, and deliberately not a heuristic — a package's shape is -// something its author decides, so it should be decidable by reading it: -// -// 1. `activate` is a function → the host's own loader -// 2. `apply` is the module's own function → a Cordis plugin (the module) -// 3. `default` is an object with `apply` → a Cordis plugin -// 4. `default` is a function → a Cordis functional plugin -// 5. anything else → the host's own loader, which -// refuses it by name -// -// Step 1 comes first so a rebon-native plugin is never mistaken for a Cordis -// one, and step 4 is last so the ambiguous case (a bare default function) goes -// to the composition rather than being guessed at from argument counts. -import { resolveEntry } from '../../plugin-host/src/loader.mjs'; +// The load request explicitly selects native, Cordis, or Claude Code mods. +// Export shape only selects a legal entry form *within* that ecosystem. +// The explicit legacy-1.9 profile alone retains the 1.9.x selection rules; +// package admission decides eligibility, and the next major removes it. +import { requireAdapter, resolveEntry, UnsupportedAdapter } from '../../plugin-host/src/loader.mjs'; +import { pluginAdapter } from '../../plugin-host/src/methods.mjs'; import { createLoader as createModsLoader, isModRequest } from '../../mods-runtime/src/index.mjs'; const own = (value, key) => value != null && Object.prototype.hasOwnProperty.call(value, key); @@ -35,7 +27,6 @@ class LoaderError extends Error { /// The Cordis plugin a module holds, or undefined if it does not hold one. export function cordisPluginOf(module) { - if (typeof module?.activate === 'function') return undefined; if (own(module, 'apply') && typeof module.apply === 'function') return module; const fallback = module?.default; if (fallback && typeof fallback === 'object' && typeof fallback.apply === 'function') return fallback; @@ -45,17 +36,26 @@ export function cordisPluginOf(module) { /// Builds the load/unload seams the host runs with. /// -/// `next` is the host's own loader, handed back untouched for anything this -/// module does not recognise — an adapter that reimplemented it would be a -/// second set of rules for the same shape. +/// `next` is the host's native loader: delegating keeps one set of rules for +/// that ecosystem. No unrecognised adapter is passed through to it. export async function createLoader({ next }) { - // A Claude Code mod is decided by the load request, before the module is - // read: rebon marked the request when it synthesised the mod's manifest, - // and the mods loader refuses to guess at one it did not mark. - const mods = await createModsLoader({ next }); + // Mod configuration still carries its scanned declarations, but only an + // explicit adapter selects that loader outside the legacy profile. + const mods = await createModsLoader(); return { async load(request) { - if (isModRequest(request)) return mods.load(request); + const adapter = pluginAdapter(request.adapter); + switch (adapter.id) { + case 'native': return next(request); + case 'claude-mods': return mods.load(request); + case 'cordis': + case 'legacy-1.9': requireAdapter(adapter, adapter.id); break; + default: throw new UnsupportedAdapter(adapter); + } + const legacy = adapter.id === 'legacy-1.9'; + if (legacy && isModRequest(request)) { + return mods.load({ ...request, adapter: { id: 'claude-mods', revision: 1 } }); + } const url = resolveEntry(request.root, request.entry); let module; try { @@ -63,11 +63,15 @@ export async function createLoader({ next }) { } catch (cause) { throw new LoaderError('[ENTRY_FAILED]', `plugin entry ${request.entry} failed to load: ${cause?.message ?? cause}`); } - const plugin = cordisPluginOf(module); - // Importing is idempotent, so handing the already-imported module back to - // the host's loader costs nothing and keeps one import per entry — which - // matters, because a module evaluated twice would register twice. - if (plugin === undefined) return next(request, async () => module); + const plugin = legacy && typeof module?.activate === 'function' + ? undefined : cordisPluginOf(module); + // Only the explicit legacy profile keeps shape-based dispatch. Importing + // is idempotent; passing the module through avoids evaluating it twice. + // The derived native request does not mutate the caller's descriptor. + if (plugin === undefined) { + if (legacy) return next({ ...request, adapter: { id: 'native', revision: 1 } }, async () => module); + throw new LoaderError('[NOT_CORDIS]', `plugin entry ${request.entry} exports no Cordis plugin (no apply)`); + } // Imported lazily: this module is loaded by the host at startup, long // before module resolution for the payload has been installed. diff --git a/runtimes/node/compose-runtime/src/probe.mjs b/runtimes/node/compose-runtime/src/probe.mjs index 28bb6bd..7cba28b 100644 --- a/runtimes/node/compose-runtime/src/probe.mjs +++ b/runtimes/node/compose-runtime/src/probe.mjs @@ -48,7 +48,7 @@ async function probe(root, entry, config) { const missing = wanted.required.filter((service) => !seats.includes(service)); const base = { name: plugin.name ?? null, inject: wanted, seats }; try { - const sink = await mountEntry({ pluginId: 'probe', config }, plugin, { probe: true }); + const sink = await mountEntry({ pluginId: 'probe', adapter: { id: 'cordis', revision: 1 }, config }, plugin, { probe: true }); const loaded = sink.loaded(); return { ok: true, diff --git a/runtimes/node/compose-runtime/test/composition.test.mjs b/runtimes/node/compose-runtime/test/composition.test.mjs index 230ece3..e4bc2ee 100644 --- a/runtimes/node/compose-runtime/test/composition.test.mjs +++ b/runtimes/node/compose-runtime/test/composition.test.mjs @@ -11,6 +11,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { loadCompose, loadEntry, openScope, plainly, startHost } from './harness.mjs'; +import { createLoader } from '../src/loader.mjs'; +import { loadPlugin } from '../../plugin-host/src/loader.mjs'; /// Writes a small Cordis plugin package and returns its load coordinates. function cordisPackage(body) { @@ -19,6 +21,108 @@ function cordisPackage(body) { return { root: root.replace(/\\/g, '/'), entry: 'entry.mjs' }; } +test('composition rejects missing, unknown and future adapters before top-level effects', async (t) => { + const pkg = cordisPackage(`import fs from 'node:fs'; fs.writeFileSync(new URL('imported', import.meta.url), 'ran'); export function activate() {}`); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const loader = await createLoader({ next: loadPlugin }); + const adapters = [undefined, { id: 'unknown', revision: 1 }, { id: 'Native', revision: 1 }]; + for (const id of ['native', 'cordis', 'claude-mods', 'legacy-1.9']) { + adapters.push({ id, revision: 0 }, { id, revision: 2 }); + } + for (const adapter of adapters) { + await assert.rejects(loader.load({ pluginId: 'refused', ...pkg, adapter }), { + code: adapter === undefined ? '[WRONG_SHAPE]' : '[UNSUPPORTED_ADAPTER]', + }); + assert.equal(fs.existsSync(path.join(pkg.root, 'imported')), false); + } +}); + +test('explicit adapters choose one ecosystem even when a module exports both', async (t) => { + const kit = await startHost(); + t.after(() => kit.stop()); + await loadCompose(kit); + const mixed = ` + export const inject = ['systemPrompt']; + export function activate(api) { api.service('native-only', () => 'native'); } + export function apply(ctx) { ctx.systemPrompt.section({ name: 'cordis-only', order: 1, text: 'cordis' }); } + `; + for (const id of ['native', 'cordis']) { + const pkg = cordisPackage(mixed); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const load = await loadEntry(kit, { id, ...pkg, adapter: { id, revision: 1 }, services: id === 'native' ? ['native-only'] : [] }); + assert.equal(load.status, 'success', JSON.stringify(load.payload)); + assert.deepEqual(plainly(load.payload.services), id === 'native' ? ['native-only'] : []); + } + await openScope(kit, 'rebon:compose'); + const report = await kit.scoped('rebon:compose', 'service/call', { service: 'compose', request: { kind: 'report', pluginId: 'cordis' } }); + assert.deepEqual(plainly(report.terminal.payload.sections), [{ name: 'cordis-only', order: 1, text: 'cordis' }]); +}); + +test('explicit adapters never retry another ecosystem after missing or failing activation', async (t) => { + const kit = await startHost(); + t.after(() => kit.stop()); + await loadCompose(kit); + const cases = [ + ['cordis', `export function activate() { mark(); }`, '[NOT_CORDIS]'], + ['native', `export function apply() { mark(); }`, '[NO_ACTIVATE]'], + ['native', `export function activate() { throw Error('native failed'); } export function apply() { mark(); }`, '[ACTIVATE_FAILED]'], + ['cordis', `export function activate() { mark(); } export function apply() { throw Error('cordis failed'); }`, null], + ]; + for (const [index, [id, body, code]] of cases.entries()) { + const pkg = cordisPackage(`import fs from 'node:fs'; function mark() { fs.writeFileSync(new URL('crossed', import.meta.url), 'ran'); } ${body}`); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const load = await loadEntry(kit, { id: `refuse-${index}`, ...pkg, adapter: { id, revision: 1 } }); + assert.equal(load.status, 'error'); + if (code) assert.equal(load.payload.code, code); + assert.equal(fs.existsSync(path.join(pkg.root, 'crossed')), false); + } +}); + +test('Cordis accepts its module apply, default object and default function forms', async (t) => { + const kit = await startHost(); + t.after(() => kit.stop()); + await loadCompose(kit); + for (const [i, body] of [ + 'export function apply() {}', + 'export default { apply() {} };', + 'export default function plugin() {}', + ].entries()) { + const pkg = cordisPackage(body); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const load = await loadEntry(kit, { id: `form-${i}`, ...pkg, adapter: { id: 'cordis', revision: 1 } }); + assert.equal(load.status, 'success', JSON.stringify(load.payload)); + } +}); + +test('mods marker is configuration, not ecosystem selection, except in explicit legacy-1.9', async (t) => { + const pkg = cordisPackage(`export function register() {} export function activate(api) { api.service('native-only', () => true); }`); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const loader = await createLoader({ next: loadPlugin }); + for (const id of ['native', 'claude-mods', 'legacy-1.9']) { + const request = Object.freeze({ pluginId: id, ...pkg, adapter: Object.freeze({ id, revision: 1 }), services: ['native-only'], config: { $claudeMod: {} } }); + const loaded = await loader.load(request); + assert.deepEqual([...loaded.services], id === 'native' ? ['native-only'] : ['mod']); + assert.equal(request.adapter.id, id); + if (id !== 'native') await loader.unload(id); + } +}); + +test('only explicit legacy-1.9 retains activate-first and Cordis default selection', async (t) => { + const kit = await startHost(); + t.after(() => kit.stop()); + await loadCompose(kit); + for (const [id, body, services] of [ + ['mixed', `export function activate(api) { api.service('legacy-native', () => true); } export function apply() { throw Error('wrong ecosystem'); }`, ['legacy-native']], + ['default', `export default function plugin() {}`, []], + ]) { + const pkg = cordisPackage(body); + t.after(() => fs.rmSync(pkg.root, { recursive: true, force: true })); + const load = await loadEntry(kit, { id, ...pkg, adapter: { id: 'legacy-1.9', revision: 1 }, services }); + assert.equal(load.status, 'success', JSON.stringify(load.payload)); + assert.deepEqual(plainly(load.payload.services), services); + } +}); + const TOOLBOX = ` import { defineTool } from '@deepseek-ai/dsh-tools'; export const name = 'toolbox'; diff --git a/runtimes/node/compose-runtime/test/harness.mjs b/runtimes/node/compose-runtime/test/harness.mjs index d69d27a..44a311b 100644 --- a/runtimes/node/compose-runtime/test/harness.mjs +++ b/runtimes/node/compose-runtime/test/harness.mjs @@ -35,11 +35,11 @@ export const PAYLOAD_ENTRY = { }; const control = (call_id, method, payload = null) => ({ - protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', + protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload }, }); const scoped = (pluginId, call_id, method, payload, generation = 1) => ({ - protocol_version: 1, host_epoch: 7, plugin_id: pluginId, scope_id: 'session.1', + protocol_version: 2, host_epoch: 7, plugin_id: pluginId, scope_id: 'session.1', scope_generation: generation, call_id, message: { type: 'request', method, payload }, }); @@ -134,6 +134,7 @@ export async function startHost({ env = {}, tools = {}, seats = {} } = {}) { export async function loadCompose(kit, { entries = [], web = null, modules = {} } = {}) { return kit.control('plugin/load', { pluginId: 'rebon:compose', + adapter: { id: 'native', revision: 1 }, root: COMPOSE_ROOT.replace(/\\/g, '/'), entry: 'src/plugin.mjs', services: ['compose'], @@ -145,6 +146,7 @@ export async function loadCompose(kit, { entries = [], web = null, modules = {} export async function loadEntry(kit, { id, specifier, root, entry, config = null, ...declared }) { return kit.control('plugin/load', { pluginId: id, + adapter: { id: 'cordis', revision: 1 }, root: root ?? PAYLOAD.replace(/\\/g, '/'), entry: entry ?? PAYLOAD_ENTRY[specifier], config, diff --git a/runtimes/node/mods-runtime/src/index.mjs b/runtimes/node/mods-runtime/src/index.mjs index e7e7824..52f0747 100644 --- a/runtimes/node/mods-runtime/src/index.mjs +++ b/runtimes/node/mods-runtime/src/index.mjs @@ -2,14 +2,15 @@ // // A mod — a Claude Code plugin of function hooks, `.claude-plugin/plugin.json` // beside a `hooks/hooks.json` naming one hooks module — is a plane plugin of -// a shape the host's own loader does not know. This decides it is one by -// reading the load request, not the module: rebon marks the request's -// `config.$claudeMod` when it synthesised the manifest from the plugin's -// folder, so what loads here is exactly what rebon said it was loading. +// a shape the host's own loader does not know. The explicit `claude-mods` +// adapter selects it; `config.$claudeMod` only carries the scanned manifest +// and options rebon synthesised from the plugin folder. // -// `createLoader({ next })` is the host's contract: `next` is the loader a -// request this one does not recognise is handed to, untouched. +// `createLoader({ next })` follows the host loader contract, but this loader +// executes only mods and never falls back to another ecosystem. import { Mod, MOD_SERVICE } from './mod.mjs'; +import { requireAdapter } from '../../plugin-host/src/loader.mjs'; +import { ProtocolError } from '../../plugin-host/src/protocol.mjs'; export { MOD_SERVICE }; export { transformJsx } from './jsx-transform.mjs'; @@ -22,16 +23,21 @@ const mods = new Map(); /// The marker rebon puts on a mod's load request. export const MARKER = '$claudeMod'; -/// Whether a load request is a mod's. +/// Whether the request carries mod configuration. This selects an ecosystem +/// only for explicit legacy-1.9; modern mods merely validate their config here. +/// Remove that selection heuristic with legacy-1.9 in the next major. export function isModRequest(request) { const marker = request?.config?.[MARKER]; return marker !== null && typeof marker === 'object' && !Array.isArray(marker); } -export async function createLoader({ next }) { +export async function createLoader() { return { async load(request) { - if (!isModRequest(request)) return next(request); + requireAdapter(request.adapter, 'claude-mods'); + if (!isModRequest(request)) { + throw new ProtocolError('[WRONG_SHAPE]', 'claude-mods requires config.$claudeMod'); + } const mod = new Mod({ pluginId: request.pluginId, root: request.root, diff --git a/runtimes/node/mods-runtime/test/client.test.mjs b/runtimes/node/mods-runtime/test/client.test.mjs index 09be668..7994abf 100644 --- a/runtimes/node/mods-runtime/test/client.test.mjs +++ b/runtimes/node/mods-runtime/test/client.test.mjs @@ -21,6 +21,7 @@ async function load() { const loader = await createLoader({ next: async () => { throw new Error('not a mod?'); } }); const sealed = await loader.load({ pluginId: 'clientmod', + adapter: { id: 'claude-mods', revision: 1 }, root, entry: 'hooks/register.tsx', services: ['mod'], diff --git a/runtimes/node/mods-runtime/test/fixtures/client-mod/.claude-plugin/plugin.json b/runtimes/node/mods-runtime/test/fixtures/client-mod/.claude-plugin/plugin.json index c88a2cb..bf68195 100644 --- a/runtimes/node/mods-runtime/test/fixtures/client-mod/.claude-plugin/plugin.json +++ b/runtimes/node/mods-runtime/test/fixtures/client-mod/.claude-plugin/plugin.json @@ -1 +1 @@ -{ "name": "clientmod", "version": "0.1.0", "description": "A pane drawn by a surface module" } +{ "name": "clientmod", "version": "0.1.0", "rebon": {"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]}, "description": "A pane drawn by a surface module" } diff --git a/runtimes/node/mods-runtime/test/fixtures/counter-mod/.claude-plugin/plugin.json b/runtimes/node/mods-runtime/test/fixtures/counter-mod/.claude-plugin/plugin.json index f5093d4..43f7a3c 100644 --- a/runtimes/node/mods-runtime/test/fixtures/counter-mod/.claude-plugin/plugin.json +++ b/runtimes/node/mods-runtime/test/fixtures/counter-mod/.claude-plugin/plugin.json @@ -1,6 +1,10 @@ { "name": "counter", "version": "0.1.0", + "rebon": { + "format": "claude-mods", "formatVersion": 1, "adapterRevision": 1, + "sdk": [{ "name": "rebon-claude-mods-api", "range": "^1" }] + }, "description": "A fixture mod: a pane with a button, a command, a tool.call rewrite and a prompt rewrite.", "userConfig": { "prefix": { "type": "string", "description": "What the status line starts with", "default": "n=" } diff --git a/runtimes/node/mods-runtime/test/fixtures/imports-mod/.claude-plugin/plugin.json b/runtimes/node/mods-runtime/test/fixtures/imports-mod/.claude-plugin/plugin.json index 7499dda..63165d0 100644 --- a/runtimes/node/mods-runtime/test/fixtures/imports-mod/.claude-plugin/plugin.json +++ b/runtimes/node/mods-runtime/test/fixtures/imports-mod/.claude-plugin/plugin.json @@ -1 +1 @@ -{ "name": "imports", "version": "0.1.0", "description": "Imports written the TypeScript way" } +{ "name": "imports", "version": "0.1.0", "rebon": {"format":"claude-mods","formatVersion":1,"adapterRevision":1,"sdk":[{"name":"rebon-claude-mods-api","range":"^1"}]}, "description": "Imports written the TypeScript way" } diff --git a/runtimes/node/mods-runtime/test/mod.test.mjs b/runtimes/node/mods-runtime/test/mod.test.mjs index 4b7c668..5a767c3 100644 --- a/runtimes/node/mods-runtime/test/mod.test.mjs +++ b/runtimes/node/mods-runtime/test/mod.test.mjs @@ -16,6 +16,7 @@ const marker = { const request = (over = {}) => ({ pluginId: 'counter', + adapter: { id: 'claude-mods', revision: 1 }, root: fixture, entry: 'hooks/register.tsx', services: ['mod'], @@ -60,13 +61,14 @@ async function loadCounter() { return { loader, sealed }; } -test('a request without the marker is handed to the next loader', async () => { - let handed; - const loader = await createLoader({ next: async (req) => { handed = req; return 'from-next'; } }); - const plain = { pluginId: 'other', root: fixture, entry: 'x.mjs', config: null }; +test('the mods loader refuses other ecosystems and missing config without falling back', async () => { + const loader = await createLoader({ next: async () => assert.fail('must not fall back') }); + for (const adapter of [{ id: 'native', revision: 1 }, { id: 'cordis', revision: 1 }, { id: 'unknown', revision: 1 }, { id: 'claude-mods', revision: 2 }]) { + await assert.rejects(loader.load(request({ adapter })), { code: '[UNSUPPORTED_ADAPTER]' }); + } + const plain = request({ config: null }); assert.ok(!isModRequest(plain)); - assert.equal(await loader.load(plain), 'from-next'); - assert.equal(handed, plain); + await assert.rejects(loader.load(plain), { code: '[WRONG_SHAPE]' }); assert.equal(await loader.unload('other'), false); }); diff --git a/runtimes/node/mods-runtime/test/resolve.test.mjs b/runtimes/node/mods-runtime/test/resolve.test.mjs index 5b93abd..e81dfb6 100644 --- a/runtimes/node/mods-runtime/test/resolve.test.mjs +++ b/runtimes/node/mods-runtime/test/resolve.test.mjs @@ -78,6 +78,7 @@ test('a hooks module with suffix-less, .js and folder imports loads and runs', a const loader = await createLoader({ next: async () => { throw new Error('not a mod?'); } }); const sealed = await loader.load({ pluginId: 'imports', + adapter: { id: 'claude-mods', revision: 1 }, root: path.join(fixtures, 'imports-mod'), entry: 'hooks/register.tsx', services: ['mod'], @@ -107,6 +108,7 @@ test('an import of a file that is not a mod file is still refused', async () => await assert.rejects( loader.load({ pluginId: 'bad', + adapter: { id: 'claude-mods', revision: 1 }, root, entry: 'hooks/register.ts', services: ['mod'], diff --git a/runtimes/node/plugin-host/src/host.mjs b/runtimes/node/plugin-host/src/host.mjs index 7cf145d..32ea481 100644 --- a/runtimes/node/plugin-host/src/host.mjs +++ b/runtimes/node/plugin-host/src/host.mjs @@ -448,6 +448,8 @@ export class PluginHost { // and so a rejection that arrives while the load is still in flight is // known to be this load's rather than someone else's later work. return { run: () => asEntryLoad(request.pluginId, async () => { + // Adapter admission belongs to the executing loader, before import. + // The default loader supports native only; composition owns the others. const loaded = await this.load(request); this.#plugins.set(request.pluginId, { phase: 'ready', diff --git a/runtimes/node/plugin-host/src/json.mjs b/runtimes/node/plugin-host/src/json.mjs index b3d82f6..4ec89dd 100644 --- a/runtimes/node/plugin-host/src/json.mjs +++ b/runtimes/node/plugin-host/src/json.mjs @@ -1,4 +1,4 @@ -import { ProtocolError, RawJsonNumber, validateEnvelope } from './protocol.mjs'; +import { PROTOCOL_VERSION, ProtocolError, RawJsonNumber, validateEnvelope } from './protocol.mjs'; class RawNumber { constructor(raw) { this.raw = raw; Object.freeze(this); } @@ -195,10 +195,10 @@ function materialize(value) { export function parseEnvelopeJson(text) { const value = new JsonParser(text).parse(); if (!value || value instanceof RawNumber || Array.isArray(value) || typeof value !== 'object') throw new ProtocolError('wrong_shape', 'envelope must be an object'); - if (!(value.protocol_version instanceof RawNumber) || value.protocol_version.raw !== '1') { - throw new ProtocolError('bad_version', 'protocol_version must be integer token 1'); + if (!(value.protocol_version instanceof RawNumber) || value.protocol_version.raw !== String(PROTOCOL_VERSION)) { + throw new ProtocolError('bad_version', `protocol_version must be integer token ${PROTOCOL_VERSION}`); } - value.protocol_version = 1; + value.protocol_version = PROTOCOL_VERSION; for (const field of ['host_epoch', 'scope_generation']) { if (!(value[field] instanceof RawNumber)) throw new ProtocolError('unsafe_integer', `${field} must be a JSON number`); value[field] = parseSafeIntegerToken(value[field].raw, field); diff --git a/runtimes/node/plugin-host/src/loader.mjs b/runtimes/node/plugin-host/src/loader.mjs index 3e7927f..fd5d86a 100644 --- a/runtimes/node/plugin-host/src/loader.mjs +++ b/runtimes/node/plugin-host/src/loader.mjs @@ -13,7 +13,22 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { ProtocolError } from './protocol.mjs'; -import { pluginCommandDefinition, pluginToolDefinition, validateName } from './methods.mjs'; +import { pluginAdapter, pluginCommandDefinition, pluginToolDefinition, validateName } from './methods.mjs'; + +export class UnsupportedAdapter extends ProtocolError { + constructor(adapter) { + super('[UNSUPPORTED_ADAPTER]', `unsupported plugin adapter ${JSON.stringify(adapter.id)} revision ${adapter.revision}`); + this.name = 'UnsupportedAdapter'; + } +} + +/// Checks the adapter this loader can execute, before importing any plugin code. +/// Other ecosystems are selected by the composition loader, not by this host. +export function requireAdapter(input, id) { + const adapter = pluginAdapter(input); + if (adapter.id !== id || adapter.revision !== 1) throw new UnsupportedAdapter(adapter); + return adapter; +} /// Resolves an entry against its package root and refuses anything outside it. /// @@ -161,6 +176,7 @@ class Registrar { /// `importModule` is injectable so the loader's rules can be tested without a /// file on disk for every case. export async function loadPlugin(request, importModule = (url) => import(url)) { + requireAdapter(request.adapter, 'native'); const url = resolveEntry(request.root, request.entry); let module; try { diff --git a/runtimes/node/plugin-host/src/methods.mjs b/runtimes/node/plugin-host/src/methods.mjs index 43f307d..06333af 100644 --- a/runtimes/node/plugin-host/src/methods.mjs +++ b/runtimes/node/plugin-host/src/methods.mjs @@ -180,12 +180,22 @@ function commandDefinitions(value, where) { return Object.freeze(out); } +export function pluginAdapter(input) { + shape(input, ['id', 'revision'], [], 'plugin adapter'); + const id = string(input.id, 'adapter.id'); + if (!Number.isInteger(input.revision) || input.revision < 0 || input.revision > 0xffffffff) { + throw new ProtocolError('[WRONG_SHAPE]', 'adapter.revision must be a u32'); + } + return Object.freeze({ id, revision: input.revision }); +} + export function pluginLoadRequest(input) { - shape(input, ['pluginId', 'root', 'entry'], ['services', 'eventTopics', 'publishedTopics', 'llmProviders', 'tools', 'commands', 'invokableTools', 'seats', 'config'], 'plugin/load payload'); + shape(input, ['pluginId', 'root', 'entry', 'adapter'], ['services', 'eventTopics', 'publishedTopics', 'llmProviders', 'tools', 'commands', 'invokableTools', 'seats', 'config'], 'plugin/load payload'); return Object.freeze({ pluginId: validatePluginId(string(input.pluginId, 'pluginId')), root: validateAbsolutePath('root', string(input.root, 'root')), entry: validateRelativePath('entry', string(input.entry, 'entry')), + adapter: pluginAdapter(input.adapter), services: declarations(input.services, 'service', 'services'), eventTopics: declarations(input.eventTopics, 'topic', 'eventTopics'), // What it may publish, as opposed to what it listens to. Two powers, two diff --git a/runtimes/node/plugin-host/src/protocol.mjs b/runtimes/node/plugin-host/src/protocol.mjs index 790601a..4d8f0f0 100644 --- a/runtimes/node/plugin-host/src/protocol.mjs +++ b/runtimes/node/plugin-host/src/protocol.mjs @@ -1,4 +1,4 @@ -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; export const MAX_FRAME_BYTES = 8 * 1024 * 1024; export const MAX_SAFE_WIRE_INTEGER = Number.MAX_SAFE_INTEGER; export const PLATFORM_PLUGIN_ID = '$rebon/platform'; diff --git a/runtimes/node/plugin-host/test/answers.test.mjs b/runtimes/node/plugin-host/test/answers.test.mjs index 724e3ab..89d804a 100644 --- a/runtimes/node/plugin-host/test/answers.test.mjs +++ b/runtimes/node/plugin-host/test/answers.test.mjs @@ -10,8 +10,8 @@ import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; import { toWireJson } from '../src/protocol.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'request', method, payload } }); const answered = (sent, call_id) => sent.filter((x) => x.message.type === 'terminal').find((x) => x.call_id === call_id)?.message; class Point { @@ -26,7 +26,7 @@ async function hostAnswering(serviceHandlers) { load: async () => ({ services: [...serviceHandlers.keys()], eventTopics: [], serviceHandlers, topicHandlers: new Map() }), }); await host.accept(control('i', 'platform/initialize')); - await host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', services: [...serviceHandlers.keys()] })); + await host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: [...serviceHandlers.keys()] })); await host.accept(scoped('o', 'scope/open', { workspace_root: 'C:/w' })); return { host, sent }; } diff --git a/runtimes/node/plugin-host/test/corpus.test.mjs b/runtimes/node/plugin-host/test/corpus.test.mjs index 0283bc8..7aadf19 100644 --- a/runtimes/node/plugin-host/test/corpus.test.mjs +++ b/runtimes/node/plugin-host/test/corpus.test.mjs @@ -4,6 +4,6 @@ import { readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { NdjsonDecoder } from '../src/framing.mjs'; const manifestUrl=new URL('../../../../crates/rebon-plugin-protocol/tests/fixtures/v1/manifest.json',import.meta.url); -function source(desc){if(desc.ascii!==undefined)return Buffer.from(desc.ascii);if(desc.hex)return Buffer.from(desc.hex,'hex');const sample={protocol_version:1,host_epoch:0,plugin_id:'p',scope_id:'s',scope_generation:0,call_id:'padding',message:{type:'notification',method:'m',payload:''}};const text=JSON.stringify(sample);const pad=desc.body_bytes-Buffer.byteLength(text);const body=Buffer.from(text.replace('""}}',`"${'x'.repeat(pad)}"}}`));assert.equal(body.length,desc.body_bytes);return Buffer.concat([body,Buffer.from(desc.ending==='crlf'?'\r\n':desc.ending==='lf'?'\n':'')]);} +function source(desc){if(desc.ascii!==undefined)return Buffer.from(desc.ascii);if(desc.hex)return Buffer.from(desc.hex,'hex');const sample={protocol_version:2,host_epoch:0,plugin_id:'p',scope_id:'s',scope_generation:0,call_id:'padding',message:{type:'notification',method:'m',payload:''}};const text=JSON.stringify(sample);const pad=desc.body_bytes-Buffer.byteLength(text);const body=Buffer.from(text.replace('""}}',`"${'x'.repeat(pad)}"}}`));assert.equal(body.length,desc.body_bytes);return Buffer.concat([body,Buffer.from(desc.ending==='crlf'?'\r\n':desc.ending==='lf'?'\n':'')]);} function normalized(value){const out={host_epoch:value.host_epoch,plugin_id:value.plugin_id,scope_id:value.scope_id,scope_generation:value.scope_generation,call_id:value.call_id,message_type:value.message.type};if(value.message.method!==undefined)out.method=value.message.method;if(value.message.status!==undefined)out.status=value.message.status;const payload=value.message.payload;if(payload?.raw!==undefined)out.payload_raw=payload.raw;if(payload?.n?.raw!==undefined)out.payload_number_raw=payload.n.raw;if(payload&&typeof payload==='object'&&payload.a!==undefined)out.payload={a:payload.a};return out;} test('shared v1 adversarial exact-byte differential corpus: 67 cases, 67 hashes, zero disagreements',async(t)=>{const manifest=JSON.parse(await readFile(manifestUrl,'utf8'));assert.equal(manifest.version,1);assert.equal(manifest.cases.length,67);let hashes=0,disagreements=0;for(const item of manifest.cases)await t.test(item.name,()=>{const bytes=source(item.source),hash=createHash('sha256').update(bytes).digest('hex');assert.equal(hash,item.sha256);hashes++;const decoder=new NdjsonDecoder();let values=[];const run=()=>{let offset=0;for(const size of item.chunks??[bytes.length]){values.push(...decoder.push(bytes.subarray(offset,Math.min(bytes.length,offset+size))));offset+=size;}if(offset ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, generation, payload, over = {}) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload }, ...over }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, generation, payload, over = {}) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload }, ...over }); /// A transport that answers whatever the host asks, the way a live supervisor /// would. Without this the host's `scope/open` would wait forever, which is @@ -42,7 +42,7 @@ const answered = (sent, call_id) => terminals(sent).find((x) => x.call_id === ca async function ready(kit, { generation = 1 } = {}) { await kit.host.accept(control('i', 'platform/initialize')); - await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', services: [], eventTopics: ['session'] })); + await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: [], eventTopics: ['session'] })); await kit.host.accept(scoped('o', 'scope/open', generation, { workspace_root: 'C:/w' })); return kit; } @@ -74,7 +74,7 @@ test('a plugin with no topics subscribes to nothing', async () => { test('a refused subscription fails the scope open that asked for it', async () => { const kit = harness({ status: 'error', answer: () => ({ code: '[UNKNOWN_TOPIC]', message: 'no such topic' }) }); await kit.host.accept(control('i', 'platform/initialize')); - await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', eventTopics: ['session'] })); + await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, eventTopics: ['session'] })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); assert.equal(answered(kit.sent, 'o').status, 'error'); // rebon's own refusal code survives the trip, rather than being flattened diff --git a/runtimes/node/plugin-host/test/framing.test.mjs b/runtimes/node/plugin-host/test/framing.test.mjs index e8f0f57..e5354f8 100644 --- a/runtimes/node/plugin-host/test/framing.test.mjs +++ b/runtimes/node/plugin-host/test/framing.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { NdjsonDecoder, SerializedWriter, encodeFrame } from '../src/framing.mjs'; -const env=(id='c')=>({protocol_version:1,host_epoch:1,plugin_id:'p',scope_id:'s',scope_generation:1,call_id:id,message:{type:'notification',method:'m',payload:null}}); +const env=(id='c')=>({protocol_version:2,host_epoch:1,plugin_id:'p',scope_id:'s',scope_generation:1,call_id:id,message:{type:'notification',method:'m',payload:null}}); test('framing accepts LF, CRLF, multiframe and every byte split',()=>{ const all=Buffer.concat([encodeFrame(env('a')),Buffer.from(encodeFrame(env('b')).subarray(0,-1).toString()+'\r\n')]); for(let split=0;split<=all.length;split++){const d=new NdjsonDecoder(); const values=[...d.push(all.subarray(0,split)),...d.push(all.subarray(split))];assert.deepEqual(values.map(v=>v.call_id),['a','b']);d.finish();}}); test('framing uses fatal UTF-8 and rejects empty/malformed',()=>{for(const [bytes,code] of [[Buffer.from([255,10]),'invalid_utf8'],[Buffer.from('\n'),'empty_frame'],[Buffer.from('{x}\n'),'malformed_json']])assert.throws(()=>new NdjsonDecoder().push(bytes),{code});}); test('fatal frame poisons same chunk and later pushes',()=>{const d=new NdjsonDecoder();assert.throws(()=>d.push(Buffer.concat([Buffer.from('{x}\n'),encodeFrame(env())])));assert.throws(()=>d.push(encodeFrame(env())),{code:'poisoned'});}); @@ -13,7 +13,7 @@ class Sink extends EventEmitter { writes=[]; blocked=false; write(bytes,callback test('serialized writer does not interleave concurrent sends and flushes',async()=>{const sink=new Sink();const writer=new SerializedWriter(sink);await Promise.all([writer.send(env('a')),writer.send(env('b'))]);await writer.flush();assert.equal(sink.writes.length,2);assert.deepEqual(sink.writes.map(x=>JSON.parse(x).call_id),['a','b']);}); test('writer awaits drain and reports write errors',async()=>{const sink=new Sink();sink.blocked=true;const writer=new SerializedWriter(sink);const sent=writer.send(env());queueMicrotask(()=>sink.emit('drain'));await sent;const bad=new Sink();bad.write=()=>{const e=new Error('closed');e.code='EPIPE';throw e;};await assert.rejects(new SerializedWriter(bad).send(env()),{code:'write_error'});}); test('writer rejects callback-time EPIPE and flush observes the failure',async()=>{const sink=new Sink();sink.write=(_bytes,callback)=>{queueMicrotask(()=>{const error=new Error('late');error.code='EPIPE';callback(error);});return true;};const writer=new SerializedWriter(sink);await assert.rejects(writer.send(env()),{code:'write_error'});await assert.rejects(writer.flush(),{code:'write_error'});assert.equal(sink.listenerCount('error'),0);assert.equal(sink.listenerCount('drain'),0);}); -test('lexical decoder rejects rounded identities, version spellings, typed duplicates, BOM and lone surrogate',()=>{const raw=(changes)=>Buffer.from(`{"protocol_version":1,"host_epoch":1,"plugin_id":"p","scope_id":"s","scope_generation":1,"call_id":"c","message":{"type":"notification","method":"m","payload":null},${changes}}\n`);for(const token of ['1.00000000000000001','9007199254740990.9','9007199254740991.1','9007199254740991.0000000001'])assert.throws(()=>new NdjsonDecoder().push(Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"host_epoch":1',`"host_epoch":${token}`))),{code:'unsafe_integer'});for(const token of ['1.0','1e0'])assert.throws(()=>new NdjsonDecoder().push(Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"protocol_version":1',`"protocol_version":${token}`))),{code:'bad_version'});assert.throws(()=>new NdjsonDecoder().push(raw('"host_\\u0065poch":2')),{code:'duplicate_field'});const direct=Buffer.from(raw('').toString().replace(',}\n',',"call_id":"d"}\n'));assert.throws(()=>new NdjsonDecoder().push(direct),{code:'duplicate_field'});assert.throws(()=>new NdjsonDecoder().push(Buffer.concat([Buffer.from([0xef,0xbb,0xbf]),Buffer.from(raw('').toString().replace(',}\n','}\n'))])),{code:'utf8_bom'});const surrogate=Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"plugin_id":"p"','"plugin_id":"\\ud800"'));assert.throws(()=>new NdjsonDecoder().push(surrogate),{code:'invalid_surrogate'});}); +test('lexical decoder rejects rounded identities, version spellings, typed duplicates, BOM and lone surrogate',()=>{const raw=(changes)=>Buffer.from(`{"protocol_version":2,"host_epoch":1,"plugin_id":"p","scope_id":"s","scope_generation":1,"call_id":"c","message":{"type":"notification","method":"m","payload":null},${changes}}\n`);for(const token of ['1.00000000000000001','9007199254740990.9','9007199254740991.1','9007199254740991.0000000001'])assert.throws(()=>new NdjsonDecoder().push(Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"host_epoch":1',`"host_epoch":${token}`))),{code:'unsafe_integer'});for(const token of ['2.0','2e0'])assert.throws(()=>new NdjsonDecoder().push(Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"protocol_version":2',`"protocol_version":${token}`))),{code:'bad_version'});assert.throws(()=>new NdjsonDecoder().push(raw('"host_\\u0065poch":2')),{code:'duplicate_field'});const direct=Buffer.from(raw('').toString().replace(',}\n',',"call_id":"d"}\n'));assert.throws(()=>new NdjsonDecoder().push(direct),{code:'duplicate_field'});assert.throws(()=>new NdjsonDecoder().push(Buffer.concat([Buffer.from([0xef,0xbb,0xbf]),Buffer.from(raw('').toString().replace(',}\n','}\n'))])),{code:'utf8_bom'});const surrogate=Buffer.from(raw('').toString().replace(',}\n','}\n').replace('"plugin_id":"p"','"plugin_id":"\\ud800"'));assert.throws(()=>new NdjsonDecoder().push(surrogate),{code:'invalid_surrogate'});}); test('tiny chunks retain linearly and concatenate at most once per completed frame',()=>{const frame=encodeFrame(env());const decoder=new NdjsonDecoder();let values=[];for(const byte of frame)values.push(...decoder.push(Buffer.of(byte)));assert.equal(values.length,1);assert.ok(decoder.stats.copied_bytes<=2*frame.length,JSON.stringify(decoder.stats));}); test('opaque non-integer payload numbers preserve their raw token and cannot be silently re-emitted',()=>{const frame=Buffer.from(JSON.stringify(env()).replace('"payload":null','"payload":0.10000000000000001')+'\n');const [value]=new NdjsonDecoder().push(frame);assert.equal(value.message.payload.raw,'0.10000000000000001');assert.ok(Object.isFrozen(value.message.payload));assert.throws(()=>encodeFrame(value),{code:'encode_error'});}); test('near-maximum one-byte splits copy at most twice the wire bytes',()=>{const target=8*1024*1024-64;const prefix=JSON.stringify({...env(),message:{type:'notification',method:'m',payload:''}});const body=Buffer.from(prefix.replace('""}}',`"${'x'.repeat(target-Buffer.byteLength(prefix))}"}}`)+'\n');const decoder=new NdjsonDecoder();let count=0;for(const byte of body)count+=decoder.push(Buffer.of(byte)).length;assert.equal(count,1);assert.ok(decoder.stats.copied_bytes<=2*body.length,JSON.stringify(decoder.stats));}); diff --git a/runtimes/node/plugin-host/test/host.test.mjs b/runtimes/node/plugin-host/test/host.test.mjs index 4c8ce50..8a7f009 100644 --- a/runtimes/node/plugin-host/test/host.test.mjs +++ b/runtimes/node/plugin-host/test/host.test.mjs @@ -1,9 +1,38 @@ import test from 'node:test';import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { FramingError } from '../src/framing.mjs'; -const control=(call_id,method,payload=null,over={})=>({protocol_version:1,host_epoch:7,plugin_id:'$rebon/platform',scope_id:'$rebon/control',scope_generation:0,call_id,message:{type:'request',method,payload},...over}); -const scope=(call_id,method,generation,payload,over={})=>({protocol_version:1,host_epoch:7,plugin_id:'plugin.a',scope_id:'scope.a',scope_generation:generation,call_id,message:{type:'request',method,payload},...over}); +const control=(call_id,method,payload=null,over={})=>({protocol_version:2,host_epoch:7,plugin_id:'$rebon/platform',scope_id:'$rebon/control',scope_generation:0,call_id,message:{type:'request',method,payload},...over}); +const scope=(call_id,method,generation,payload,over={})=>({protocol_version:2,host_epoch:7,plugin_id:'plugin.a',scope_id:'scope.a',scope_generation:generation,call_id,message:{type:'request',method,payload},...over}); const setup=(writerOverride)=>{const sent=[];const writer=writerOverride??{send:async x=>sent.push(x),flush:async()=>{writer.flushed=true}};return {host:new PluginHost(writer),sent,writer}}; +test('wire admission rejects missing and malformed adapters before module side effects', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rebon-adapter-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'entry.mjs'), `import fs from 'node:fs'; fs.writeFileSync(new URL('imported', import.meta.url), 'ran'); export function activate() {}`); + const { host, sent } = setup(); + await host.accept(control('init', 'platform/initialize')); + for (const [i, [adapter, code]] of [ + [undefined, '[MISSING_FIELD]'], [null, '[WRONG_SHAPE]'], + [{ id: 'native' }, '[MISSING_FIELD]'], + [{ id: 'native', revision: 1, extra: true }, '[UNKNOWN_FIELD]'], + [{ id: 'native', revision: 1.5 }, '[WRONG_SHAPE]'], + [{ id: 'future', revision: 1 }, '[UNSUPPORTED_ADAPTER]'], + [{ id: 'native', revision: 2 }, '[UNSUPPORTED_ADAPTER]'], + [{ id: 'cordis', revision: 1 }, '[UNSUPPORTED_ADAPTER]'], + [{ id: 'claude-mods', revision: 1 }, '[UNSUPPORTED_ADAPTER]'], + [{ id: 'legacy-1.9', revision: 1 }, '[UNSUPPORTED_ADAPTER]'], + ].entries()) { + const payload = { pluginId: 'refused', root, entry: 'entry.mjs' }; + if (adapter !== undefined) payload.adapter = adapter; + await host.accept(control(`load-${i}`, 'plugin/load', payload)); + assert.equal(sent.at(-1).message.status, 'error'); + assert.equal(sent.at(-1).message.payload.code, code); + assert.equal(fs.existsSync(path.join(root, 'imported')), false); + } +}); + test('initialize exactly once, reserved identity, epoch and real capability',async()=>{const {host,sent}=setup();await host.dispatch(control('i','platform/initialize',{host_epoch:7}));assert.equal(sent[0].message.status,'success');assert.equal(JSON.stringify(sent[0].message.payload),JSON.stringify({capabilities:{scope_lifecycle:true}}));await host.dispatch(control('i2','platform/initialize'));assert.equal(sent[1].message.payload.code,'already_initialized');}); test('initialize payload validation occurs before host state mutation',async()=>{const {host,sent}=setup();await host.dispatch(control('bad','platform/initialize',{workspace_root:'x'}));assert.equal(host.state,'pre_initialize');assert.equal(sent[0].message.payload.code,'workspace_in_initialize');await host.dispatch(control('i','platform/initialize'));assert.equal(host.state,'initialized');}); test('open validation and failed ledger registration do not mutate scope state',async()=>{const {host,sent}=setup();await host.dispatch(control('i','platform/initialize'));await host.dispatch(scope('bad','scope/open',3,{workspace_root:4}));assert.equal(sent.at(-1).message.payload.code,'scope_open_payload');await host.dispatch(scope('o','scope/open',3,{workspace_root:'w'}));assert.equal(sent.at(-1).message.status,'success');await assert.rejects(host.dispatch(scope('o','scope/close',4,null)),{code:'call_id_reused'});await host.dispatch(scope('c','scope/close',4,null));assert.equal(sent.at(-1).message.status,'success');}); diff --git a/runtimes/node/plugin-host/test/loader.test.mjs b/runtimes/node/plugin-host/test/loader.test.mjs index dd5c311..09f3e2c 100644 --- a/runtimes/node/plugin-host/test/loader.test.mjs +++ b/runtimes/node/plugin-host/test/loader.test.mjs @@ -3,9 +3,20 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { loadPlugin, resolveEntry } from '../src/loader.mjs'; -const request=(over={})=>({pluginId:'plugin.a',root:path.resolve('/packages/demo'),entry:'index.mjs',services:['compose'],eventTopics:['session'],...over}); +const request=(over={})=>({adapter:{id:'native',revision:1},pluginId:'plugin.a',root:path.resolve('/packages/demo'),entry:'index.mjs',services:['compose'],eventTopics:['session'],...over}); const module_=(activate)=>async()=>({activate}); +test('unsupported or missing adapters are rejected before importing the entry', async () => { + for (const adapter of [undefined, { id: 'future', revision: 1 }, { id: 'native', revision: 2 }, { id: 'cordis', revision: 1 }, { id: 'claude-mods', revision: 1 }]) { + let imported = false; + await assert.rejects(loadPlugin(request({ adapter }), async () => { + imported = true; + return { activate() {} }; + }), { code: adapter === undefined ? '[WRONG_SHAPE]' : '[UNSUPPORTED_ADAPTER]' }); + assert.equal(imported, false); + } +}); + test('an entry resolves inside its package root',()=>{const root=path.resolve('/packages/demo');const url=resolveEntry(root,'src/index.mjs');assert.ok(url.startsWith('file:'));assert.ok(decodeURIComponent(url).includes('src'));}); // The payload validator rejects `..` in the text; this is the check that still diff --git a/runtimes/node/plugin-host/test/methods.test.mjs b/runtimes/node/plugin-host/test/methods.test.mjs index 6d6800d..b31dc7c 100644 --- a/runtimes/node/plugin-host/test/methods.test.mjs +++ b/runtimes/node/plugin-host/test/methods.test.mjs @@ -14,6 +14,6 @@ test('control characters are rejected across the whole Cc category',()=>{for(con test('the platform identity is refused after the generic name rules',()=>{assert.throws(()=>validatePluginId(''),{code:'[EMPTY_NAME]'});assert.throws(()=>validatePluginId('$rebon/platform'),{code:'[RESERVED_PLUGIN_ID]'});assert.equal(validatePluginId('plugin.a'),'plugin.a');}); -test('validated payloads are frozen and drop nothing they accepted',()=>{const loaded=VALIDATORS.plugin_load({pluginId:'plugin.a',root:'/pkg',entry:'index.mjs',services:['compose']});assert.ok(Object.isFrozen(loaded));assert.deepEqual(loaded.services,['compose']);assert.deepEqual(loaded.eventTopics,[]);const call=VALIDATORS.service_call({service:'compose',request:{n:1}});assert.deepEqual(call.request,{n:1});}); +test('validated payloads are frozen and drop nothing they accepted',()=>{const loaded=VALIDATORS.plugin_load({adapter:{id:'native',revision:1},pluginId:'plugin.a',root:'/pkg',entry:'index.mjs',services:['compose']});assert.ok(Object.isFrozen(loaded));assert.deepEqual(loaded.services,['compose']);assert.deepEqual(loaded.eventTopics,[]);const call=VALIDATORS.service_call({service:'compose',request:{n:1}});assert.deepEqual(call.request,{n:1});}); -test('declaration limits are checked before the entries they bound',()=>{const many=Array.from({length:257},(_,index)=>String(index));assert.throws(()=>VALIDATORS.plugin_load({pluginId:'p',root:'/pkg',entry:'i',services:many}),{code:'[TOO_MANY_DECLARATIONS]'});const bad=Array.from({length:257},()=>'');assert.throws(()=>VALIDATORS.plugin_load({pluginId:'p',root:'/pkg',entry:'i',services:bad}),{code:'[TOO_MANY_DECLARATIONS]'},'the count is rejected before any entry is read');assert.throws(()=>VALIDATORS.plugin_load({pluginId:'p',root:'/pkg',entry:'i',services:['','']}),{code:'[EMPTY_NAME]'},'and an invalid entry before the duplicate check');}); +test('declaration limits are checked before the entries they bound',()=>{const many=Array.from({length:257},(_,index)=>String(index));assert.throws(()=>VALIDATORS.plugin_load({adapter:{id:'native',revision:1},pluginId:'p',root:'/pkg',entry:'i',services:many}),{code:'[TOO_MANY_DECLARATIONS]'});const bad=Array.from({length:257},()=>'');assert.throws(()=>VALIDATORS.plugin_load({adapter:{id:'native',revision:1},pluginId:'p',root:'/pkg',entry:'i',services:bad}),{code:'[TOO_MANY_DECLARATIONS]'},'the count is rejected before any entry is read');assert.throws(()=>VALIDATORS.plugin_load({adapter:{id:'native',revision:1},pluginId:'p',root:'/pkg',entry:'i',services:['','']}),{code:'[EMPTY_NAME]'},'and an invalid entry before the duplicate check');}); diff --git a/runtimes/node/plugin-host/test/process.test.mjs b/runtimes/node/plugin-host/test/process.test.mjs index 8d8b5c8..8e3843b 100644 --- a/runtimes/node/plugin-host/test/process.test.mjs +++ b/runtimes/node/plugin-host/test/process.test.mjs @@ -1,8 +1,16 @@ import test from 'node:test';import assert from 'node:assert/strict';import { spawn } from 'node:child_process';import { once } from 'node:events';import { fileURLToPath } from 'node:url'; const cli=fileURLToPath(new URL('../src/cli.mjs',import.meta.url)); -const frame=(identity,method,payload)=>JSON.stringify({protocol_version:1,...identity,message:{type:'request',method,payload}})+'\n'; +const frame=(identity,method,payload)=>JSON.stringify({protocol_version:2,...identity,message:{type:'request',method,payload}})+'\n'; const control=(call_id)=>({host_epoch:7,plugin_id:'$rebon/platform',scope_id:'$rebon/control',scope_generation:0,call_id});const scope=(call_id,g)=>({host_epoch:7,plugin_id:'plugin.a',scope_id:'scope.a',scope_generation:g,call_id}); async function run(input){const child=spawn(process.execPath,[cli],{stdio:['pipe','pipe','pipe']});let stdout='',stderr='';child.stdout.setEncoding('utf8').on('data',x=>stdout+=x);child.stderr.setEncoding('utf8').on('data',x=>stderr+=x);child.stdin.end(input);const [code]=await once(child,'exit');return {code,stdout,stderr};} +test('protocol v1 is refused during initialize before host activation', async () => { + const result = await run(frame({ ...control('old'), protocol_version: 1 }, 'platform/initialize', null)); + assert.equal(result.code, 2); + assert.equal(result.stdout, ''); + const diagnostic = JSON.parse(result.stderr); + assert.equal(diagnostic.code, 'bad_version'); + assert.match(diagnostic.message, /2/); +}); test('process good initialize/open/close/shutdown lifecycle exits zero',async()=>{const input=frame(control('i'),'platform/initialize',null)+frame(scope('o',3),'scope/open',{workspace_root:'C:/w'})+frame(scope('c',4),'scope/close',null)+frame(control('s'),'platform/shutdown',null);const result=await run(input);assert.equal(result.code,0);assert.equal(result.stderr,'');const lines=result.stdout.trim().split('\n').map(JSON.parse);assert.deepEqual(lines.map(x=>x.call_id).sort(),['c','i','o','s']);assert.ok(lines.every(x=>x.message.type==='terminal'&&x.message.status==='success'));}); test('late output-pipe failure during shutdown exits with write_error',async()=>{const child=spawn(process.execPath,[cli],{stdio:['pipe','pipe','pipe']});let stderr='';child.stderr.setEncoding('utf8').on('data',x=>stderr+=x);child.stdin.write(frame(control('i'),'platform/initialize',null));await once(child.stdout,'data');child.stdout.destroy();child.stdin.end(frame(control('s'),'platform/shutdown',null));const [code]=await once(child,'exit');assert.equal(code,4);assert.equal(JSON.parse(stderr).code,'write_error');}); test('shutdown plus valid extra in same chunk writes shutdown terminal then exits 2',async()=>{const result=await run(frame(control('i'),'platform/initialize',null)+frame(control('s'),'platform/shutdown',null)+frame(control('late'),'platform/initialize',null));assert.equal(result.code,2);assert.deepEqual(result.stdout.trim().split('\n').map(x=>JSON.parse(x).call_id).sort(),['i','s']);assert.equal(JSON.parse(result.stderr).code,'frame_after_shutdown');}); diff --git a/runtimes/node/plugin-host/test/protocol.test.mjs b/runtimes/node/plugin-host/test/protocol.test.mjs index a2777c4..a96aede 100644 --- a/runtimes/node/plugin-host/test/protocol.test.mjs +++ b/runtimes/node/plugin-host/test/protocol.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { validateEnvelope, envelope, terminal, cancel, request, PLATFORM_PLUGIN_ID, PLATFORM_CONTROL_SCOPE_ID } from '../src/protocol.mjs'; import { encodeFrame } from '../src/framing.mjs'; -const base = { protocol_version:1, host_epoch:7, plugin_id:'p', scope_id:'s', scope_generation:3, call_id:'c', message:{type:'request',method:'m',payload:null} }; +const base = { protocol_version:2, host_epoch:7, plugin_id:'p', scope_id:'s', scope_generation:3, call_id:'c', message:{type:'request',method:'m',payload:null} }; test('accepts exact three variants and arbitrary JSON payloads', () => { for (const message of [{type:'request',method:'',payload:42},{type:'notification',method:'n',payload:null},{type:'terminal',status:'error',payload:[1]}]) assert.equal(validateEnvelope({...base,message}).message.type,message.type); }); @@ -10,7 +10,7 @@ test('rejects non-object and unknown envelope/message keys', () => { for (const value of [null,1,[],{...base,x:1},{...base,message:{...base.message,x:1}}]) assert.throws(() => validateEnvelope(value)); }); test('rejects bad version, status and identity field types', () => { - assert.throws(() => validateEnvelope({...base,protocol_version:2}), {code:'bad_version'}); + for (const protocol_version of [0, 1, 3]) assert.throws(() => validateEnvelope({...base, protocol_version}), {code:'bad_version'}); assert.throws(() => validateEnvelope({...base,message:{type:'terminal',status:'done',payload:null}}), {code:'bad_status'}); assert.throws(() => validateEnvelope({...base,plugin_id:1}), {code:'wrong_shape'}); }); @@ -27,7 +27,7 @@ test('builders revalidate outbound envelopes', () => assert.throws(() => envelop test('control and cancel constants match exact Rust wire identity', () => { assert.equal(PLATFORM_PLUGIN_ID,'$rebon/platform'); assert.equal(PLATFORM_CONTROL_SCOPE_ID,'$rebon/control'); const control=request({host_epoch:7,plugin_id:PLATFORM_PLUGIN_ID,scope_id:PLATFORM_CONTROL_SCOPE_ID,scope_generation:0,call_id:'control-1'},'platform/initialize',null); - assert.equal(encodeFrame(control).toString(),'{"protocol_version":1,"host_epoch":7,"plugin_id":"$rebon/platform","scope_id":"$rebon/control","scope_generation":0,"call_id":"control-1","message":{"type":"request","method":"platform/initialize","payload":null}}\n'); + assert.equal(encodeFrame(control).toString(),'{"protocol_version":2,"host_epoch":7,"plugin_id":"$rebon/platform","scope_id":"$rebon/control","scope_generation":0,"call_id":"control-1","message":{"type":"request","method":"platform/initialize","payload":null}}\n'); const value=cancel({host_epoch:7,plugin_id:'p',scope_id:'s',scope_generation:3,call_id:'target'}); assert.deepEqual(value.message,{type:'notification',method:'call/cancel',payload:null}); assert.equal(terminal(value,'success',null).call_id,'target'); }); diff --git a/runtimes/node/plugin-host/test/publish.test.mjs b/runtimes/node/plugin-host/test/publish.test.mjs index d1a797a..4346e30 100644 --- a/runtimes/node/plugin-host/test/publish.test.mjs +++ b/runtimes/node/plugin-host/test/publish.test.mjs @@ -9,8 +9,8 @@ import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; import { identityOf, terminal } from '../src/protocol.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, generation, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, generation, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); /// A rebon that accepts published events, or refuses them the way a real one /// would when the topic reaches nobody. @@ -55,7 +55,7 @@ const plainly = (value) => JSON.parse(JSON.stringify(value)); async function ready(kit, publishedTopics = ['session']) { await kit.host.accept(control('i', 'platform/initialize')); await kit.host.accept(control('l', 'plugin/load', { - pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', + pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: ['announce', 'refused'], publishedTopics, })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); @@ -97,7 +97,7 @@ test('listening to a topic does not grant publishing it', async () => { const kit = harness(); await kit.host.accept(control('i', 'platform/initialize')); await kit.host.accept(control('l', 'plugin/load', { - pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', + pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: ['announce', 'refused'], eventTopics: ['session'], })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); diff --git a/runtimes/node/plugin-host/test/retire.test.mjs b/runtimes/node/plugin-host/test/retire.test.mjs index 7983c3b..5d07637 100644 --- a/runtimes/node/plugin-host/test/retire.test.mjs +++ b/runtimes/node/plugin-host/test/retire.test.mjs @@ -10,8 +10,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'request', method, payload } }); const answered = (sent, call_id) => sent.filter((x) => x.message.type === 'terminal').find((x) => x.call_id === call_id)?.message; function harness() { @@ -39,7 +39,7 @@ function harness() { async function ready(kit) { await kit.host.accept(control('i', 'platform/initialize')); - await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', services: ['quick', 'slow'] })); + await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: ['quick', 'slow'] })); await kit.host.accept(scoped('o', 'scope/open', { workspace_root: 'C:/w' })); return kit; } @@ -85,7 +85,7 @@ test('a host with no loader teardown still drains', async () => { load: async () => ({ services: [], eventTopics: [], serviceHandlers: new Map(), topicHandlers: new Map() }), }); await host.accept(control('i', 'platform/initialize')); - await host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs' })); + await host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 } })); await host.accept(control('u', 'plugin/unload', { pluginId: 'plugin.a' })); assert.equal(answered(sent, 'u').status, 'success'); diff --git a/runtimes/node/plugin-host/test/scope-handle.test.mjs b/runtimes/node/plugin-host/test/scope-handle.test.mjs index 6905a60..8b97e55 100644 --- a/runtimes/node/plugin-host/test/scope-handle.test.mjs +++ b/runtimes/node/plugin-host/test/scope-handle.test.mjs @@ -11,8 +11,8 @@ import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; import { identityOf, terminal } from '../src/protocol.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, generation, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, generation, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); const answered = (sent, call_id) => sent.filter((x) => x.message.type === 'terminal').find((x) => x.call_id === call_id)?.message; const upstream = (sent, method) => sent.filter((x) => x.message.type === 'request' && x.message.method === method); @@ -43,7 +43,7 @@ function harness({ handlers } = {}) { async function ready(kit, load = {}) { await kit.host.accept(control('i', 'platform/initialize')); await kit.host.accept(control('l', 'plugin/load', { - pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', + pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, publishedTopics: ['loop:event'], seats: ['logger'], invokableTools: ['read_file'], ...load, })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); diff --git a/runtimes/node/plugin-host/test/streams.test.mjs b/runtimes/node/plugin-host/test/streams.test.mjs index 0ac69bc..f07e755 100644 --- a/runtimes/node/plugin-host/test/streams.test.mjs +++ b/runtimes/node/plugin-host/test/streams.test.mjs @@ -8,8 +8,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, generation, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, generation, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); function harness() { const sent = []; @@ -48,7 +48,7 @@ const chunks = (sent, call_id) => sent.filter((x) => x.message.type === 'chunk' async function ready(kit) { await kit.host.accept(control('i', 'platform/initialize')); - await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', services: ['count', 'leak', 'late', 'patient'] })); + await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: ['count', 'leak', 'late', 'patient'] })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); return kit; } @@ -82,7 +82,7 @@ test('a context that outlived its call cannot emit', async () => { assert.equal(chunks(kit.sent, 'a').length, 0, 'nothing was emitted after the end'); }); -const cancelFor = (call_id) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'notification', method: 'call/cancel', payload: null } }); +const cancelFor = (call_id) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: 1, call_id, message: { type: 'notification', method: 'call/cancel', payload: null } }); // Cancel asks; it does not command. A handler that stops by returning has not // failed, and reporting it as an error would make a deliberate stop look like a diff --git a/runtimes/node/plugin-host/test/tools.test.mjs b/runtimes/node/plugin-host/test/tools.test.mjs index 08e381b..8ccc3ea 100644 --- a/runtimes/node/plugin-host/test/tools.test.mjs +++ b/runtimes/node/plugin-host/test/tools.test.mjs @@ -8,8 +8,8 @@ import assert from 'node:assert/strict'; import { PluginHost } from '../src/host.mjs'; import { identityOf, terminal } from '../src/protocol.mjs'; -const control = (call_id, method, payload = null) => ({ protocol_version: 1, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); -const scoped = (call_id, method, generation, payload) => ({ protocol_version: 1, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); +const control = (call_id, method, payload = null) => ({ protocol_version: 2, host_epoch: 7, plugin_id: '$rebon/platform', scope_id: '$rebon/control', scope_generation: 0, call_id, message: { type: 'request', method, payload } }); +const scoped = (call_id, method, generation, payload) => ({ protocol_version: 2, host_epoch: 7, plugin_id: 'plugin.a', scope_id: 'scope.a', scope_generation: generation, call_id, message: { type: 'request', method, payload } }); /// A rebon that answers `tool/invoke` however the test needs. /// @@ -62,7 +62,7 @@ const shape = (value) => JSON.parse(JSON.stringify(value)); async function ready(kit) { await kit.host.accept(control('i', 'platform/initialize')); - await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', services: ['use', 'look', 'fail', 'explode'], eventTopics: ['session'], invokableTools: kit.tools })); + await kit.host.accept(control('l', 'plugin/load', { pluginId: 'plugin.a', root: '/pkg', entry: 'index.mjs', adapter: { id: 'native', revision: 1 }, services: ['use', 'look', 'fail', 'explode'], eventTopics: ['session'], invokableTools: kit.tools })); await kit.host.accept(scoped('o', 'scope/open', 1, { workspace_root: 'C:/w' })); return kit; } diff --git a/runtimes/node/plugins/deepseek-responses/rebon-plugin.json b/runtimes/node/plugins/deepseek-responses/rebon-plugin.json index 131aa58..cd12722 100644 --- a/runtimes/node/plugins/deepseek-responses/rebon-plugin.json +++ b/runtimes/node/plugins/deepseek-responses/rebon-plugin.json @@ -1,6 +1,12 @@ { "name": "deepseek-responses", "version": "0.5.0", + "compatibility": { + "format": "rebon-plugin", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "rebon-plugin-api", "range": "^1" }] + }, "description": "DeepSeek Responses API model provider (deepseek-v4-pro, deepseek-flash) running as an llm/stream adapter on rebon's plugin plane.", "capabilities": { "modelProviders": { diff --git a/runtimes/node/plugins/examples/hello-command/rebon-plugin.json b/runtimes/node/plugins/examples/hello-command/rebon-plugin.json index 6958ace..59ce765 100644 --- a/runtimes/node/plugins/examples/hello-command/rebon-plugin.json +++ b/runtimes/node/plugins/examples/hello-command/rebon-plugin.json @@ -1,6 +1,12 @@ { "name": "hello-command", "version": "0.1.0", + "compatibility": { + "format": "rebon-plugin", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "rebon-plugin-api", "range": "^1" }] + }, "description": "The smallest kernel-plane plugin there is: one module, one slash command. Documentation and end-to-end fixture; nothing here loads unless a config.json asks for it.", "capabilities": { "kernelPlugins": { diff --git a/runtimes/node/plugins/rebon-black-hole/rebon-plugin.json b/runtimes/node/plugins/rebon-black-hole/rebon-plugin.json index de1244a..ecc7452 100644 --- a/runtimes/node/plugins/rebon-black-hole/rebon-plugin.json +++ b/runtimes/node/plugins/rebon-black-hole/rebon-plugin.json @@ -1,6 +1,12 @@ { "name": "rebon-black-hole", "version": "0.1.0", + "compatibility": { + "format": "rebon-plugin", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "rebon-plugin-api", "range": "^1" }] + }, "description": "A window-wide gravitational black-hole lens that drifts in from the upper-right corner.", "capabilities": { "appVisualEffects": [ diff --git a/runtimes/node/plugins/rebon-browser/rebon-plugin.json b/runtimes/node/plugins/rebon-browser/rebon-plugin.json index 9f85046..8a1437b 100644 --- a/runtimes/node/plugins/rebon-browser/rebon-plugin.json +++ b/runtimes/node/plugins/rebon-browser/rebon-plugin.json @@ -1,6 +1,12 @@ { "name": "rebon-browser", "version": "0.1.0", + "compatibility": { + "format": "rebon-plugin", + "formatVersion": 1, + "adapterRevision": 1, + "sdk": [{ "name": "rebon-plugin-api", "range": "^1" }] + }, "description": "Control a dedicated Chrome or Edge tab group through the local Rebon browser extension.", "capabilities": { "mcpServers": {