diff --git a/Cargo.lock b/Cargo.lock index b927be029..54d562583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,6 +1059,7 @@ dependencies = [ "bitfun-claude-code-adapter", "bitfun-codex-adapter", "bitfun-core-types", + "bitfun-dsh-adapter", "bitfun-events", "bitfun-external-sources", "bitfun-harness", @@ -1204,6 +1205,23 @@ dependencies = [ "zstd", ] +[[package]] +name = "bitfun-dsh-adapter" +version = "0.2.17" +dependencies = [ + "async-trait", + "bitfun-plugin-runtime-client", + "bitfun-product-domains", + "bitfun-runtime-ports", + "hex", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "urlencoding", +] + [[package]] name = "bitfun-events" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 7e16b968d..a651908ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/dsh-adapter", "src/crates/adapters/claude-code-adapter", "src/crates/adapters/codex-adapter", "src/crates/adapters/static-hook-support", diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 47d3c8024..54d9d872a 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -39,6 +39,7 @@ export const crateLayoutRules = [ { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, + { crateName: 'dsh-adapter', layer: 'adapters', path: 'src/crates/adapters/dsh-adapter' }, { crateName: 'static-hook-support', layer: 'adapters', path: 'src/crates/adapters/static-hook-support' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 15e0032a7..43b4dea69 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -10,6 +10,7 @@ const agentRuntimeIpcForbiddenDeps = [ 'bitfun-codex-adapter', 'bitfun-core', 'bitfun-core-types', + 'bitfun-dsh-adapter', 'bitfun-external-sources', 'bitfun-harness', 'bitfun-opencode-adapter', @@ -62,6 +63,7 @@ export const noCoreDependencyCrates = [ 'product-domains', 'claude-code-adapter', 'codex-adapter', + 'dsh-adapter', 'opencode-adapter', 'static-hook-support', 'external-sources', @@ -117,6 +119,19 @@ export const forbiddenManifestDependencyRules = [ message: 'only bitfun-core product-full assembly may register bitfun-opencode-adapter through reviewed capability composition roots', }, + { + dependencyNames: ['bitfun-dsh-adapter'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + 'src/crates/adapters/dsh-adapter/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', + ], + reason: + 'DeepSeek Harness adapter production dependencies are limited to the reviewed product composition root', + message: + 'only bitfun-core product-full assembly may register bitfun-dsh-adapter through reviewed capability composition roots', + }, ...[ ['bitfun-claude-code-adapter', 'claude-code-adapter'], ['bitfun-codex-adapter', 'codex-adapter'], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 2bb2832f7..810d6c268 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -124,6 +124,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-external-sources', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-harness', ownerFeatures: ['agent-runtime'] }, { depName: 'bitfun-opencode-adapter', ownerFeatures: ['external-sources'] }, + { depName: 'bitfun-dsh-adapter', ownerFeatures: ['external-sources'] }, { depName: 'bitfun-plugin-runtime-client', ownerFeatures: ['plugin-runtime'] }, { depName: 'bitfun-product-capabilities', ownerFeatures: ['product-capabilities'] }, { @@ -390,6 +391,9 @@ export const capabilityContractDependencyRules = [ capabilityEdge(['plugin-runtime']), capabilityEdge(['script-tool-runtime'], { kind: 'dev' }), ])], + ['bitfun-dsh-adapter', capabilityConsumer([ + capabilityEdge(['plugin-runtime']), + ])], ['bitfun-plugin-runtime-client', capabilityConsumer([ capabilityEdge(['plugin-runtime']), ])], @@ -712,6 +716,7 @@ export const coreClosedFeatureProfileRules = [ 'mcp-runtime', 'script-tool-runtime', 'dep:bitfun-opencode-adapter', + 'dep:bitfun-dsh-adapter', 'dep:bitfun-claude-code-adapter', 'dep:bitfun-codex-adapter', 'dep:bitfun-external-sources', diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index 42ba50528..ccc1c62a9 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -14,6 +14,7 @@ services. | `agent-runtime-ipc` | Non-published private local IPC adapter for the opt-in first-party Shared TUI Runtime; closed interactive operations only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode source semantics for user Instructions plus the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | +| `dsh-adapter` | DeepSeek Harness (`dsh`) bundle/profile source projection for managed packages; static preview only | [AGENTS.md](dsh-adapter/AGENTS.md) | | `claude-code-adapter` | Runtime-free Claude Code user Instructions, Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | | `codex-adapter` | Runtime-free Codex user Instructions, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](codex-adapter/AGENTS.md) | | `static-hook-support` | Shared bounded/redacting static-source utilities plus the JSON/TOML Hook parser used by sibling ecosystem adapters; no ecosystem policy or runtime | inherited | diff --git a/src/crates/adapters/dsh-adapter/AGENTS.md b/src/crates/adapters/dsh-adapter/AGENTS.md new file mode 100644 index 000000000..c7d742cce --- /dev/null +++ b/src/crates/adapters/dsh-adapter/AGENTS.md @@ -0,0 +1,34 @@ +# DeepSeek Harness (dsh) Adapter + +This crate owns the static, runtime-free projection of DeepSeek Harness (`dsh`) +plugin package sources. It reads a BitFun-managed package whose +`bitfun.plugin.json` declares `adapter: "dsh_compatible"`, parses the package's +`package.json` `dsh` declaration, and projects the discovered bundle entries +(`dsh.bundle.patch` -> `cordis.patch.yml` rows) and/or profile bundle references +(`dsh.profile.bundles`) as projection-only plugin sources. + +It does not execute Cordis plugins, install npm packages, or depend on a +user-local `dsh` CLI. Execution of dsh bundles belongs to future Plugin Host / +external-ACP work, not this adapter boundary. + +## Boundary Rules + +- Depend on stable contracts (`bitfun-runtime-ports`, `bitfun-product-domains`) + and the `PluginRuntimeAdapter` boundary trait. Do not depend on `bitfun-core`, + app crates, Tauri APIs, product UI, or concrete service managers. +- Keep the dsh `package.json` `dsh` field shape and `cordis.patch.yml` entry + extraction inside this crate. Cross-crate outputs use typed + `PluginSourceRef` / `PluginStatusSnapshot` / `PluginDiagnostic` DTOs; do not + expose raw dsh YAML or JSON as product contracts. +- Cordis rows may mount services that register model-facing tools when dsh runs + them, but static row metadata is not an executable BitFun provider candidate. + `load_dsh_package_adapter` therefore returns no provider dispatch targets. + Unsupported or unparsable content must produce typed invalid projections and + diagnostics, never silent success. +- New ecosystems are sibling adapters registered by Product Assembly + (`bitfun-core/plugin_runtime`), not modes of this adapter. + +## Verification + +- `cargo test --locked -p bitfun-dsh-adapter --test dsh_source_adapter` +- `cargo test --locked -p bitfun-core --no-default-features --features plugin-runtime --lib plugin_runtime::tests` diff --git a/src/crates/adapters/dsh-adapter/Cargo.toml b/src/crates/adapters/dsh-adapter/Cargo.toml new file mode 100644 index 000000000..da6025a77 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "bitfun-dsh-adapter" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "DeepSeek Harness (dsh) bundle source projection adapter for BitFun" +autotests = false + +[lib] +name = "bitfun_dsh_adapter" +crate-type = ["rlib"] + +[dependencies] +async-trait = { workspace = true } +bitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client" } +bitfun-product-domains = { path = "../../contracts/product-domains", features = ["plugin-source"] } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["plugin-runtime"] } +hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +sha2 = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[[test]] +name = "dsh_source_adapter" +path = "tests/dsh_source_adapter.rs" + +[lints] +workspace = true diff --git a/src/crates/adapters/dsh-adapter/src/lib.rs b/src/crates/adapters/dsh-adapter/src/lib.rs new file mode 100644 index 000000000..4ac717765 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/src/lib.rs @@ -0,0 +1,11 @@ +//! DeepSeek Harness (`dsh`) bundle source adapter. +//! +//! The production surface is intentionally small: load a `dsh`-compatible +//! managed package (its `package.json` bundle/profile declaration and any +//! declared `cordis.patch.yml` bundle patch) as a projection-only plugin runtime +//! adapter. The adapter does not execute Cordis plugins, install npm packages, +//! or depend on a user-local `dsh` CLI. + +mod source_adapter; + +pub use source_adapter::load_dsh_package_adapter; diff --git a/src/crates/adapters/dsh-adapter/src/source_adapter.rs b/src/crates/adapters/dsh-adapter/src/source_adapter.rs new file mode 100644 index 000000000..d382e5498 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/src/source_adapter.rs @@ -0,0 +1,1361 @@ +//! DeepSeek Harness (`dsh`) bundle source projection. +//! +//! The adapter covers the `dsh` distribution shape: a `package.json` whose +//! `dsh` field declares a bundle (`dsh.bundle.patch` -> a +//! `cordis.patch.yml` list of Cordis plugin rows), a profile +//! (`dsh.profile.bundles` -> an ordered list of bundle names). It projects the +//! discovered entries for either or both roles as projection-only plugin +//! sources. It does not execute JavaScript, install packages, or become the +//! runtime client. + +use async_trait::async_trait; +use bitfun_plugin_runtime_client::PluginRuntimeAdapter; +use bitfun_product_domains::plugin_source::{ + PluginActivationAuthority, PluginPackageInput, PluginPackageSourceIdentity, +}; +use bitfun_runtime_ports::{ + PluginAuditRef, PluginConfigValidationIssue, PluginConfigValidationState, + PluginConfigValidationStatus, PluginDiagnostic, PluginDiagnosticDetail, + PluginDiagnosticSeverity, PluginDispatchEnvelope, PluginManifestRef, PluginResponseEnvelope, + PluginRuntimeAvailability, PluginRuntimeEpochs, PluginRuntimeReadRequest, + PluginRuntimeReadResponse, PluginRuntimeUnavailableReason, PluginSourceKind, PluginSourceRef, + PluginStatusKind, PluginStatusSnapshot, PluginTrustLevel, PortError, PortErrorKind, PortResult, +}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +const DSH_ADAPTER_ID: &str = "dsh-compatible"; +const DSH_MANIFEST_ADAPTER_ID: &str = "dsh_compatible"; +const DSH_PACKAGE_SCHEMA_VERSION: &str = "dsh.package.v1"; +const DSH_BUNDLE_SCHEMA_VERSION: &str = "dsh.bundle.v1"; +const DSH_PROFILE_SCHEMA_VERSION: &str = "dsh.profile.v1"; +const PACKAGE_JSON: &str = "package.json"; +const MAX_PLUGIN_ID_COMPONENT_LEN: usize = 40; +const MAX_ENTRIES_PER_PACKAGE: usize = 256; +const MAX_ENTRY_ID_BYTES: usize = 256; +const MAX_PROFILE_BUNDLES: usize = 128; +const MAX_PROFILE_BUNDLE_NAME_BYTES: usize = 256; + +struct DshPluginRuntimeAdapter { + projections: Vec, + observed_at_ms: u64, + activation: Option, +} + +struct DshActivationContext { + project_domain_id: String, + workspace_id: String, + activation_epoch: u64, +} + +impl DshPluginRuntimeAdapter { + fn from_package(input: PluginPackageInput, observed_at_ms: u64) -> PortResult { + let (manifest, source, files) = input.into_parts(); + if manifest.adapter != DSH_MANIFEST_ADAPTER_ID { + return Err(adapter_port_error(format!( + "managed package adapter is not dsh-compatible: {}", + manifest.adapter + ))); + } + let provenance_id = sha256_content_hash(&source.source_path) + .trim_start_matches("sha256:") + .to_string(); + let package_uri = format!( + "bitfun://managed-plugins/{provenance_id}/{}", + urlencoding::encode(&source.package_id) + ); + let package_json_uri = format!( + "bitfun://managed-plugins/{provenance_id}/{}/{PACKAGE_JSON}", + source.package_id + ); + let projections = project_package( + &files, + &provenance_id, + &source, + &package_uri, + &package_json_uri, + observed_at_ms, + )?; + Ok(Self { + projections, + observed_at_ms, + activation: None, + }) + } + + fn from_activated_package( + input: PluginPackageInput, + authority: PluginActivationAuthority, + observed_at_ms: u64, + ) -> PortResult { + let (project_domain_id, workspace_id, authority_source, activation_epoch) = + authority.into_parts(); + let (manifest, source, files) = input.into_parts(); + if source != authority_source { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "dsh package input does not match its activation authority", + )); + } + let input = PluginPackageInput::new(manifest, source, files) + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; + let mut adapter = Self::from_package(input, observed_at_ms)?; + if adapter.projections.iter().any(DshProjection::is_invalid) { + return Err(adapter_port_error( + "invalid dsh package projection cannot be activated".to_string(), + )); + } + for projection in &mut adapter.projections { + projection.activate_supported_source(); + } + adapter.activation = Some(DshActivationContext { + project_domain_id, + workspace_id, + activation_epoch, + }); + Ok(adapter) + } + + fn validate_activation_scope( + &self, + project_domain_id: &str, + workspace_id: &str, + epochs: &PluginRuntimeEpochs, + ) -> PortResult<()> { + let Some(activation) = &self.activation else { + return Ok(()); + }; + if activation.project_domain_id != project_domain_id + || activation.workspace_id != workspace_id + || activation.activation_epoch != epochs.trust_epoch + { + return Err(PortError::new( + PortErrorKind::NotAvailable, + "dsh package activation scope or epoch is stale", + )); + } + Ok(()) + } + + fn activation_matches( + &self, + project_domain_id: &str, + workspace_id: &str, + epochs: &PluginRuntimeEpochs, + ) -> bool { + self.activation.as_ref().is_none_or(|activation| { + activation.project_domain_id == project_domain_id + && activation.workspace_id == workspace_id + && activation.activation_epoch == epochs.trust_epoch + }) + } + + fn projection_for_source(&self, source: &PluginSourceRef) -> Option<&DshProjection> { + self.projections + .iter() + .find(|projection| source_identity_matches(projection.source_ref(), source)) + } + + fn source_mismatch_response(&self, envelope: PluginDispatchEnvelope) -> PluginResponseEnvelope { + self.unavailable_response( + envelope, + "dsh.source_mismatch", + "dsh dispatch source does not match a loaded source snapshot", + false, + ) + } + + fn activation_stale_response( + &self, + envelope: PluginDispatchEnvelope, + ) -> PluginResponseEnvelope { + self.unavailable_response( + envelope, + "dsh.activation_stale", + "dsh package activation scope or epoch is stale", + true, + ) + } + + fn unavailable_response( + &self, + envelope: PluginDispatchEnvelope, + code: &str, + message: &str, + retryable: bool, + ) -> PluginResponseEnvelope { + let diagnostic_id = format!( + "diag:{}:dispatch:{}:{}", + envelope.source.plugin_id, envelope.event_id, code + ); + let diagnostic = PluginDiagnostic { + diagnostic_id: diagnostic_id.clone(), + severity: PluginDiagnosticSeverity::Warning, + source: envelope.source.clone(), + code: code.to_string(), + message: message.to_string(), + detail: PluginDiagnosticDetail::Adapter { + adapter_id: DSH_ADAPTER_ID.to_string(), + }, + audit: audit_ref(&envelope), + retryable, + }; + + PluginResponseEnvelope { + envelope_version: envelope.envelope_version, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: DSH_ADAPTER_ID.to_string(), + plugin_id: Some(envelope.source.plugin_id.clone()), + completed_at_ms: self.observed_at_ms, + effects: Vec::new(), + diagnostics: vec![diagnostic], + quarantine: None, + plugin_statuses: vec![PluginStatusSnapshot { + source: envelope.source.clone(), + status: PluginStatusKind::Unavailable, + availability: PluginRuntimeAvailability::Unavailable { + reason: PluginRuntimeUnavailableReason::HostUnavailable, + }, + config_validation: None, + quarantine: None, + diagnostic_ids: vec![diagnostic_id], + updated_at_ms: self.observed_at_ms, + }], + observed_epochs: envelope.epochs, + } + } +} + +#[async_trait] +impl PluginRuntimeAdapter for DshPluginRuntimeAdapter { + fn adapter_id(&self) -> &str { + DSH_ADAPTER_ID + } + + fn availability(&self) -> PluginRuntimeAvailability { + PluginRuntimeAvailability::projection_only(PluginRuntimeUnavailableReason::HostUnavailable) + } + + async fn read_plugins( + &self, + request: PluginRuntimeReadRequest, + ) -> PortResult { + self.validate_activation_scope( + &request.project_domain_id, + &request.workspace_id, + &request.epochs, + )?; + let mut sources = Vec::new(); + let mut plugin_statuses = Vec::new(); + let mut diagnostics = Vec::new(); + + for projection in self.projections.iter().filter(|projection| { + request.plugin_ids.is_empty() + || request + .plugin_ids + .iter() + .any(|plugin_id| plugin_id == &projection.source_ref().plugin_id) + }) { + let projection_diagnostics = projection.read_diagnostics(); + let diagnostic_ids = projection_diagnostics + .iter() + .map(|diagnostic| diagnostic.diagnostic_id.clone()) + .collect(); + sources.push(projection.source_ref().clone()); + plugin_statuses.push( + projection.status_snapshot(request.include_config_validation, diagnostic_ids), + ); + diagnostics.extend(projection_diagnostics); + } + + Ok(PluginRuntimeReadResponse { + request_id: request.request_id, + project_domain_id: request.project_domain_id, + workspace_id: request.workspace_id, + sources, + plugin_statuses, + diagnostics, + observed_epochs: request.epochs, + }) + } + + async fn dispatch( + &self, + envelope: PluginDispatchEnvelope, + ) -> PortResult { + if !self.activation_matches( + &envelope.project_domain_id, + &envelope.workspace_id, + &envelope.epochs, + ) { + return Ok(self.activation_stale_response(envelope)); + } + match self.projection_for_source(&envelope.source) { + Some(projection) => projection.project_dispatch_response(envelope), + None => Ok(self.source_mismatch_response(envelope)), + } + } +} + +// Product Assembly consumes the same compatibility tuple exposed by the +// sibling OpenCode adapter. +#[allow(clippy::type_complexity)] +pub fn load_dsh_package_adapter( + input: PluginPackageInput, + activation: Option, + observed_at_ms: u64, +) -> PortResult<( + Arc, + Vec<( + PluginSourceRef, + String, + bitfun_runtime_ports::PluginCapabilityRef, + Vec<( + bitfun_runtime_ports::PluginTargetRef, + bitfun_runtime_ports::PluginRiskLevel, + )>, + )>, +)> { + let adapter = match activation { + Some(authority) => { + DshPluginRuntimeAdapter::from_activated_package(input, authority, observed_at_ms)? + } + None => DshPluginRuntimeAdapter::from_package(input, observed_at_ms)?, + }; + // Cordis rows can mount services that later register model-facing tools in + // dsh, but static row metadata is not a safe executable BitFun provider + // candidate. This runtime-free adapter therefore exposes no dispatch target. + Ok((Arc::new(adapter), Vec::new())) +} + +fn source_identity_matches(left: &PluginSourceRef, right: &PluginSourceRef) -> bool { + left.plugin_id == right.plugin_id + && left.source_kind == right.source_kind + && left.source == right.source + && left.version == right.version + && left.content_hash == right.content_hash +} + +enum DshProjection { + Entry(DshEntryProjection), + Invalid(DshInvalidProjection), +} + +impl DshProjection { + fn is_invalid(&self) -> bool { + matches!(self, Self::Invalid(_)) + } + + fn activate_supported_source(&mut self) { + if let Self::Entry(projection) = self { + projection.source.trust_level = PluginTrustLevel::Trusted; + } + } + + fn source_ref(&self) -> &PluginSourceRef { + match self { + Self::Entry(projection) => projection.source_ref(), + Self::Invalid(projection) => projection.source_ref(), + } + } + + fn read_diagnostics(&self) -> Vec { + match self { + Self::Entry(projection) => projection.read_diagnostics(), + Self::Invalid(projection) => projection.read_diagnostics(), + } + } + + fn status_snapshot( + &self, + include_config_validation: bool, + diagnostic_ids: Vec, + ) -> PluginStatusSnapshot { + match self { + Self::Entry(projection) => { + projection.status_snapshot(include_config_validation, diagnostic_ids) + } + Self::Invalid(projection) => { + projection.status_snapshot(include_config_validation, diagnostic_ids) + } + } + } + + fn project_dispatch_response( + &self, + envelope: PluginDispatchEnvelope, + ) -> PortResult { + match self { + Self::Entry(projection) => projection.project_dispatch_response(envelope), + Self::Invalid(projection) => projection.project_dispatch_response(envelope), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DshEntryKind { + Bundle, + ProfileBundle, +} + +impl DshEntryKind { + fn diagnostic_code(self) -> &'static str { + match self { + Self::Bundle => "dsh.bundle_entry_projection_only", + Self::ProfileBundle => "dsh.profile_bundle_projection_only", + } + } + + fn diagnostic_message(self) -> &'static str { + match self { + Self::Bundle => { + "dsh bundle entry is discovered from cordis.patch.yml but is not installed or executed by BitFun" + } + Self::ProfileBundle => { + "dsh profile bundle reference is discovered from package.json but is not installed or executed by BitFun" + } + } + } + + fn schema_version(self) -> &'static str { + match self { + Self::Bundle => DSH_BUNDLE_SCHEMA_VERSION, + Self::ProfileBundle => DSH_PROFILE_SCHEMA_VERSION, + } + } +} + +struct DshEntryProjection { + entry_id: String, + source: PluginSourceRef, + manifest: PluginManifestRef, + entry_kind: DshEntryKind, + observed_at_ms: u64, +} + +impl DshEntryProjection { + #[allow(clippy::too_many_arguments)] + fn new( + entry_id: String, + source_uri: String, + manifest_path: String, + entry_kind: DshEntryKind, + version: &str, + content_hash: &str, + observed_at_ms: u64, + ) -> Self { + let plugin_id = stable_plugin_id( + "dsh.entry", + &sanitize_plugin_id_component(&entry_id), + &source_uri, + ); + let manifest = PluginManifestRef { + manifest_id: format!("{plugin_id}:{}", entry_kind.schema_version()), + schema_version: entry_kind.schema_version().to_string(), + path: Some(manifest_path), + }; + Self { + entry_id, + source: PluginSourceRef { + plugin_id: plugin_id.clone(), + source_kind: PluginSourceKind::DeepSeekHarnessCompatible, + source: source_uri, + version: Some(version.to_string()), + content_hash: content_hash.to_string(), + trust_level: PluginTrustLevel::Unknown, + manifest: Some(manifest.clone()), + }, + manifest, + entry_kind, + observed_at_ms, + } + } + + fn source_ref(&self) -> &PluginSourceRef { + &self.source + } + + fn read_diagnostics(&self) -> Vec { + let mut diagnostics = Vec::new(); + if self.source.trust_level != PluginTrustLevel::Trusted { + diagnostics.push(self.trust_diagnostic()); + } + diagnostics.push(self.entry_diagnostic()); + diagnostics + } + + fn status_snapshot( + &self, + include_config_validation: bool, + diagnostic_ids: Vec, + ) -> PluginStatusSnapshot { + let (availability, status) = Self::trust_status_for_level(self.source.trust_level); + PluginStatusSnapshot { + source: self.source.clone(), + status, + availability, + config_validation: include_config_validation.then(|| PluginConfigValidationState { + status: PluginConfigValidationStatus::Valid, + issues: Vec::new(), + }), + quarantine: None, + diagnostic_ids, + updated_at_ms: self.observed_at_ms, + } + } + + fn project_dispatch_response( + &self, + envelope: PluginDispatchEnvelope, + ) -> PortResult { + if envelope.source.plugin_id != self.source.plugin_id { + return Err(PortError::new( + PortErrorKind::NotFound, + format!( + "dsh source {} is not loaded by this adapter", + envelope.source.plugin_id + ), + )); + } + let diagnostics = self.dispatch_diagnostics(&envelope); + let diagnostic_ids = diagnostics + .iter() + .map(|diagnostic| diagnostic.diagnostic_id.clone()) + .collect(); + let (availability, status) = Self::trust_status_for_level(self.source.trust_level); + + Ok(PluginResponseEnvelope { + envelope_version: envelope.envelope_version, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: DSH_ADAPTER_ID.to_string(), + plugin_id: Some(envelope.source.plugin_id.clone()), + completed_at_ms: self.observed_at_ms, + effects: Vec::new(), + diagnostics, + quarantine: None, + plugin_statuses: vec![PluginStatusSnapshot { + source: envelope.source.clone(), + status, + availability, + config_validation: None, + quarantine: None, + diagnostic_ids, + updated_at_ms: self.observed_at_ms, + }], + observed_epochs: envelope.epochs, + }) + } + + fn dispatch_diagnostics(&self, envelope: &PluginDispatchEnvelope) -> Vec { + let mut entry = self.entry_diagnostic(); + entry.diagnostic_id = format!( + "diag:{}:dispatch:{}:entry", + self.source.plugin_id, envelope.event_id + ); + entry.source = envelope.source.clone(); + entry.audit = audit_ref(envelope); + + if self.source.trust_level == PluginTrustLevel::Trusted { + return vec![entry]; + } + + let mut trust = self.trust_diagnostic(); + trust.diagnostic_id = format!( + "diag:{}:dispatch:{}:trust", + self.source.plugin_id, envelope.event_id + ); + trust.source = envelope.source.clone(); + trust.audit = audit_ref(envelope); + vec![trust, entry] + } + + fn trust_diagnostic(&self) -> PluginDiagnostic { + PluginDiagnostic { + diagnostic_id: format!("diag:{}:trust", self.source.plugin_id), + severity: PluginDiagnosticSeverity::Warning, + source: self.source.clone(), + code: "dsh.trust_required".to_string(), + message: "dsh bundle entry is not trusted for projection".to_string(), + detail: PluginDiagnosticDetail::Trust { + trust_level: self.source.trust_level, + }, + audit: PluginAuditRef { + correlation_id: format!("trust:{}", self.source.plugin_id), + event_id: None, + }, + retryable: false, + } + } + + fn entry_diagnostic(&self) -> PluginDiagnostic { + PluginDiagnostic { + diagnostic_id: format!("diag:{}:entry:{}", self.source.plugin_id, self.entry_id), + severity: PluginDiagnosticSeverity::Info, + source: self.source.clone(), + code: self.entry_kind.diagnostic_code().to_string(), + message: format!( + "{}: {}", + self.entry_kind.diagnostic_message(), + self.entry_id + ), + detail: PluginDiagnosticDetail::Manifest { + manifest: self.manifest.clone(), + }, + audit: PluginAuditRef { + correlation_id: format!("config:{}", self.source.plugin_id), + event_id: None, + }, + retryable: false, + } + } + + fn trust_status_for_level( + trust_level: PluginTrustLevel, + ) -> (PluginRuntimeAvailability, PluginStatusKind) { + match trust_level { + PluginTrustLevel::Trusted => ( + PluginRuntimeAvailability::projection_only( + PluginRuntimeUnavailableReason::HostUnavailable, + ), + PluginStatusKind::ProjectionOnly, + ), + PluginTrustLevel::Denied | PluginTrustLevel::Revoked => ( + PluginRuntimeAvailability::disabled( + PluginRuntimeUnavailableReason::DisabledByPolicy, + ), + PluginStatusKind::Disabled, + ), + _ => ( + PluginRuntimeAvailability::projection_only( + PluginRuntimeUnavailableReason::DisabledByPolicy, + ), + PluginStatusKind::TrustRequired, + ), + } + } +} + +struct DshInvalidProjection { + source: PluginSourceRef, + validation: PluginConfigValidationState, + diagnostic_code: String, + diagnostic_message: String, + diagnostic_detail_manifest: PluginManifestRef, + observed_at_ms: u64, +} + +impl DshInvalidProjection { + fn invalid( + source_uri: &str, + package_source: &PluginPackageSourceIdentity, + code: &str, + field: &str, + message: String, + observed_at_ms: u64, + ) -> Self { + let plugin_id = stable_plugin_id( + "dsh.package", + &sanitize_plugin_id_component(code), + &format!("{source_uri}#{code}"), + ); + let manifest = PluginManifestRef { + manifest_id: format!("{plugin_id}:{DSH_PACKAGE_SCHEMA_VERSION}"), + schema_version: DSH_PACKAGE_SCHEMA_VERSION.to_string(), + path: Some(source_uri.to_string()), + }; + Self { + source: PluginSourceRef { + plugin_id, + source_kind: PluginSourceKind::DeepSeekHarnessCompatible, + source: source_uri.to_string(), + version: Some(package_source.version.clone()), + content_hash: package_source.content_hash.clone(), + trust_level: PluginTrustLevel::Unknown, + manifest: Some(manifest.clone()), + }, + validation: invalid_validation(field, code, &message), + diagnostic_code: code.to_string(), + diagnostic_message: message, + diagnostic_detail_manifest: manifest, + observed_at_ms, + } + } + + fn source_ref(&self) -> &PluginSourceRef { + &self.source + } + + fn read_diagnostics(&self) -> Vec { + vec![self.diagnostic(None)] + } + + fn status_snapshot( + &self, + include_config_validation: bool, + diagnostic_ids: Vec, + ) -> PluginStatusSnapshot { + PluginStatusSnapshot { + source: self.source.clone(), + status: PluginStatusKind::InvalidConfig, + availability: PluginRuntimeAvailability::projection_only( + PluginRuntimeUnavailableReason::DisabledByPolicy, + ), + config_validation: include_config_validation.then(|| self.validation.clone()), + quarantine: None, + diagnostic_ids, + updated_at_ms: self.observed_at_ms, + } + } + + fn project_dispatch_response( + &self, + envelope: PluginDispatchEnvelope, + ) -> PortResult { + if envelope.source.plugin_id != self.source.plugin_id { + return Err(PortError::new( + PortErrorKind::NotFound, + format!( + "dsh source {} is not loaded by this adapter", + envelope.source.plugin_id + ), + )); + } + let diagnostics = vec![self.diagnostic(Some(&envelope))]; + let diagnostic_ids = diagnostics + .iter() + .map(|diagnostic| diagnostic.diagnostic_id.clone()) + .collect(); + Ok(PluginResponseEnvelope { + envelope_version: envelope.envelope_version, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: DSH_ADAPTER_ID.to_string(), + plugin_id: Some(envelope.source.plugin_id.clone()), + completed_at_ms: self.observed_at_ms, + effects: Vec::new(), + diagnostics, + quarantine: None, + plugin_statuses: vec![PluginStatusSnapshot { + source: envelope.source.clone(), + status: PluginStatusKind::InvalidConfig, + availability: PluginRuntimeAvailability::projection_only( + PluginRuntimeUnavailableReason::DisabledByPolicy, + ), + config_validation: None, + quarantine: None, + diagnostic_ids, + updated_at_ms: self.observed_at_ms, + }], + observed_epochs: envelope.epochs, + }) + } + + fn diagnostic(&self, envelope: Option<&PluginDispatchEnvelope>) -> PluginDiagnostic { + let diagnostic_id = match envelope { + Some(envelope) => format!( + "diag:{}:dispatch:{}:{}", + self.source.plugin_id, envelope.event_id, self.diagnostic_code + ), + None => format!("diag:{}:{}", self.source.plugin_id, self.diagnostic_code), + }; + PluginDiagnostic { + diagnostic_id, + severity: PluginDiagnosticSeverity::Error, + source: envelope + .map_or_else(|| self.source.clone(), |envelope| envelope.source.clone()), + code: self.diagnostic_code.clone(), + message: self.diagnostic_message.clone(), + detail: PluginDiagnosticDetail::ConfigValidation { + manifest: self.diagnostic_detail_manifest.clone(), + validation: self.validation.clone(), + }, + audit: envelope.map_or_else( + || PluginAuditRef { + correlation_id: format!("invalid:{}", self.source.plugin_id), + event_id: None, + }, + audit_ref, + ), + retryable: false, + } + } +} + +#[derive(Debug, Deserialize)] +struct DshPackageDoc { + #[serde(default)] + dsh: Option, +} + +#[derive(Debug, Deserialize)] +struct DshPackageField { + #[serde(default)] + bundle: Option, + #[serde(default)] + profile: Option, +} + +#[derive(Debug, Deserialize)] +struct DshBundleDecl { + #[serde(default)] + patch: Option, +} + +#[derive(Debug, Deserialize)] +struct DshProfileDecl { + #[serde(default)] + bundles: Vec, +} + +#[derive(Debug, Deserialize)] +struct DshPatchOperation { + #[serde(default)] + id: Option, + #[serde(default)] + insert: Option>, + #[serde(default)] + group: Option, + #[serde(default)] + config: Option, +} + +#[derive(Debug, Deserialize)] +struct DshCordisEntry { + #[serde(default)] + id: Option, + #[serde(default)] + group: Option, + #[serde(default)] + config: Option, +} + +fn project_package( + files: &BTreeMap>, + provenance_id: &str, + source: &PluginPackageSourceIdentity, + package_uri: &str, + package_json_uri: &str, + observed_at_ms: u64, +) -> PortResult> { + let mut projections = Vec::new(); + + let Some(package_doc) = read_package_doc( + files, + source, + package_json_uri, + observed_at_ms, + &mut projections, + ) else { + return Ok(projections); + }; + + let Some(field) = package_doc.dsh.as_ref() else { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.package_no_dsh_field", + "dsh", + "package.json declares no dsh bundle or profile".to_string(), + observed_at_ms, + ))); + return Ok(projections); + }; + + let mut declared_role = false; + if let Some(bundle) = field.bundle.as_ref() { + declared_role = true; + project_bundle( + bundle, + files, + provenance_id, + source, + package_json_uri, + observed_at_ms, + &mut projections, + )?; + } + if let Some(profile) = field.profile.as_ref() { + declared_role = true; + project_profile( + profile, + source, + package_uri, + package_json_uri, + observed_at_ms, + &mut projections, + )?; + } + if !declared_role { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.package_no_supported_role", + "dsh", + "dsh declaration has no supported bundle or profile role".to_string(), + observed_at_ms, + ))); + } + + Ok(projections) +} + +fn read_package_doc( + files: &BTreeMap>, + source: &PluginPackageSourceIdentity, + package_json_uri: &str, + observed_at_ms: u64, + projections: &mut Vec, +) -> Option { + let Some(bytes) = files.get(PACKAGE_JSON) else { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.package_json_missing", + "package.json", + "managed package has no package.json".to_string(), + observed_at_ms, + ))); + return None; + }; + let json = match std::str::from_utf8(bytes) { + Ok(json) => json, + Err(error) => { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.package_json_invalid", + "package.json", + format!("package.json must be UTF-8: {error}"), + observed_at_ms, + ))); + return None; + } + }; + match serde_json::from_str::(json) { + Ok(doc) => Some(doc), + Err(error) => { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.package_json_invalid", + "package.json", + error.to_string(), + observed_at_ms, + ))); + None + } + } +} + +#[allow(clippy::too_many_arguments)] +fn project_bundle( + bundle: &DshBundleDecl, + files: &BTreeMap>, + provenance_id: &str, + source: &PluginPackageSourceIdentity, + package_json_uri: &str, + observed_at_ms: u64, + projections: &mut Vec, +) -> PortResult<()> { + let Some(declared_patch) = bundle.patch.as_deref().filter(|patch| !patch.is_empty()) else { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.bundle_patch_missing", + "dsh.bundle.patch", + "dsh bundle declaration must name its patch file".to_string(), + observed_at_ms, + ))); + return Ok(()); + }; + let Some(patch_rel) = normalize_relative_path(declared_patch) else { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.bundle_patch_invalid", + "dsh.bundle.patch", + "dsh bundle patch must resolve to a file inside the managed package".to_string(), + observed_at_ms, + ))); + return Ok(()); + }; + let patch_uri = managed_source_uri(provenance_id, &source.package_id, &patch_rel); + + let Some(bytes) = files.get(&patch_rel) else { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.bundle_patch_missing", + "dsh.bundle.patch", + format!("declared bundle patch file is not part of the package: {patch_rel}"), + observed_at_ms, + ))); + return Ok(()); + }; + + let patch_yaml = match std::str::from_utf8(bytes) { + Ok(yaml) => yaml, + Err(error) => { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.bundle_patch_invalid", + "dsh.bundle.patch", + format!("bundle patch must be UTF-8: {error}"), + observed_at_ms, + ))); + return Ok(()); + } + }; + + if patch_yaml.trim().is_empty() { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + &patch_uri, + source, + "dsh.bundle_no_entries", + "cordis.patch.yml", + "bundle patch declares no cordis entries".to_string(), + observed_at_ms, + ))); + return Ok(()); + } + + let entries = match serde_yaml::from_str::>(patch_yaml) { + Ok(entries) => entries, + Err(error) => { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + &patch_uri, + source, + "dsh.bundle_patch_invalid", + "cordis.patch.yml", + format!("bundle patch must be a YAML list: {error}"), + observed_at_ms, + ))); + return Ok(()); + } + }; + + let mut entry_ids = Vec::new(); + let mut seen = HashSet::new(); + let mut visited_entries = 0usize; + let mut unprojectable_entries = 0usize; + collect_entry_ids( + &entries, + &mut entry_ids, + &mut seen, + &mut visited_entries, + &mut unprojectable_entries, + ); + if visited_entries > MAX_ENTRIES_PER_PACKAGE { + return Err(adapter_port_error(format!( + "managed package declares more than {MAX_ENTRIES_PER_PACKAGE} dsh bundle entries" + ))); + } + if unprojectable_entries > 0 { + let invalid_uri = format!("{patch_uri}#unprojectable-entry"); + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + &invalid_uri, + source, + "dsh.bundle_entry_invalid", + "cordis.patch.yml", + format!( + "bundle patch contains {unprojectable_entries} Cordis entry or group value(s) without a valid stable identity" + ), + observed_at_ms, + ))); + } + if entry_ids.is_empty() { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + &patch_uri, + source, + "dsh.bundle_no_entries", + "cordis.patch.yml", + "bundle patch declares no cordis entries".to_string(), + observed_at_ms, + ))); + return Ok(()); + } + + for entry_id in entry_ids { + let source_uri = format!("{patch_uri}#entry={}", urlencoding::encode(&entry_id)); + projections.push(DshProjection::Entry(DshEntryProjection::new( + entry_id, + source_uri, + patch_uri.clone(), + DshEntryKind::Bundle, + &source.version, + &source.content_hash, + observed_at_ms, + ))); + } + Ok(()) +} + +fn project_profile( + profile: &DshProfileDecl, + source: &PluginPackageSourceIdentity, + package_uri: &str, + package_json_uri: &str, + observed_at_ms: u64, + projections: &mut Vec, +) -> PortResult<()> { + if profile.bundles.len() > MAX_PROFILE_BUNDLES { + return Err(adapter_port_error(format!( + "managed package declares more than {MAX_PROFILE_BUNDLES} dsh profile bundles" + ))); + } + if profile.bundles.is_empty() { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.profile_no_bundles", + "dsh.profile.bundles", + "dsh profile declares no bundles".to_string(), + observed_at_ms, + ))); + return Ok(()); + } + + let mut projected = 0usize; + for (index, name) in profile.bundles.iter().enumerate() { + if name.is_empty() + || name.trim() != name + || name.len() > MAX_PROFILE_BUNDLE_NAME_BYTES + || name.chars().any(|ch| ch.is_control() || ch.is_whitespace()) + { + let invalid_uri = format!("{package_json_uri}#bundle-index={index}"); + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + &invalid_uri, + source, + "dsh.profile_bundle_invalid", + &format!("dsh.profile.bundles[{index}]"), + "dsh profile bundle name must be a non-empty package name without whitespace" + .to_string(), + observed_at_ms, + ))); + continue; + } + projected += 1; + let source_uri = format!( + "{package_uri}#bundle-index={index}&bundle={}", + urlencoding::encode(name) + ); + projections.push(DshProjection::Entry(DshEntryProjection::new( + name.to_string(), + source_uri, + package_json_uri.to_string(), + DshEntryKind::ProfileBundle, + &source.version, + &source.content_hash, + observed_at_ms, + ))); + } + + if projected == 0 { + projections.push(DshProjection::Invalid(DshInvalidProjection::invalid( + package_json_uri, + source, + "dsh.profile_no_bundles", + "dsh.profile.bundles", + "dsh profile declares no valid bundle names".to_string(), + observed_at_ms, + ))); + } + Ok(()) +} + +fn collect_entry_ids( + entries: &[DshPatchOperation], + out: &mut Vec, + seen: &mut HashSet, + visited_entries: &mut usize, + unprojectable_entries: &mut usize, +) { + for operation in entries { + *visited_entries = (*visited_entries).saturating_add(1); + if *visited_entries > MAX_ENTRIES_PER_PACKAGE { + return; + } + if let Some(id) = operation.id.as_deref() { + collect_stable_entry_id(id, out, seen, unprojectable_entries); + } + if let Some(insert) = operation.insert.as_deref() { + collect_cordis_entries(insert, out, seen, visited_entries, unprojectable_entries); + if *visited_entries > MAX_ENTRIES_PER_PACKAGE { + return; + } + } + collect_group_entries( + operation.group, + operation.config.as_ref(), + out, + seen, + visited_entries, + unprojectable_entries, + ); + } +} + +fn collect_cordis_entries( + entries: &[DshCordisEntry], + out: &mut Vec, + seen: &mut HashSet, + visited_entries: &mut usize, + unprojectable_entries: &mut usize, +) { + for entry in entries { + *visited_entries = (*visited_entries).saturating_add(1); + if *visited_entries > MAX_ENTRIES_PER_PACKAGE { + return; + } + match entry.id.as_deref() { + Some(id) => collect_stable_entry_id(id, out, seen, unprojectable_entries), + None => *unprojectable_entries = (*unprojectable_entries).saturating_add(1), + } + collect_group_entries( + entry.group, + entry.config.as_ref(), + out, + seen, + visited_entries, + unprojectable_entries, + ); + } +} + +fn collect_group_entries( + group: Option, + config: Option<&serde_yaml::Value>, + out: &mut Vec, + seen: &mut HashSet, + visited_entries: &mut usize, + unprojectable_entries: &mut usize, +) { + if group != Some(true) { + return; + } + let Some(serde_yaml::Value::Sequence(values)) = config else { + if config.is_some() { + *unprojectable_entries = (*unprojectable_entries).saturating_add(1); + } + return; + }; + let mut entries = Vec::with_capacity(values.len()); + for value in values { + match serde_yaml::from_value::(value.clone()) { + Ok(entry) => entries.push(entry), + Err(_) => *unprojectable_entries = (*unprojectable_entries).saturating_add(1), + } + } + collect_cordis_entries(&entries, out, seen, visited_entries, unprojectable_entries); +} + +fn collect_stable_entry_id( + id: &str, + out: &mut Vec, + seen: &mut HashSet, + unprojectable_entries: &mut usize, +) { + if id.is_empty() || id.len() > MAX_ENTRY_ID_BYTES || id.chars().any(char::is_control) { + *unprojectable_entries = (*unprojectable_entries).saturating_add(1); + return; + } + if seen.insert(id.to_string()) { + out.push(id.to_string()); + } +} + +fn normalize_relative_path(path: &str) -> Option { + if path.starts_with('/') || path.contains('\\') { + return None; + } + + let mut segments = Vec::new(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + segments.pop()?; + } + _ if segment.contains(':') || segment.chars().any(char::is_control) => return None, + _ => segments.push(segment), + } + } + (!segments.is_empty()).then(|| segments.join("/")) +} + +fn stable_plugin_id(prefix: &str, component: &str, identity: &str) -> String { + let digest = hex::encode(Sha256::digest(identity.as_bytes())); + format!("{prefix}.{component}.{}", &digest[..32]) +} + +fn sha256_content_hash(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + +fn managed_source_uri(provenance_id: &str, package_id: &str, relative_path: &str) -> String { + let encoded_path = relative_path + .split('/') + .map(|segment| urlencoding::encode(segment).into_owned()) + .collect::>() + .join("/"); + format!( + "bitfun://managed-plugins/{provenance_id}/{}/{encoded_path}", + urlencoding::encode(package_id) + ) +} + +fn sanitize_plugin_id_component(value: &str) -> String { + let mut sanitized = String::with_capacity(value.len().min(MAX_PLUGIN_ID_COMPONENT_LEN)); + let mut previous_separator = false; + for ch in value.chars() { + if sanitized.len() >= MAX_PLUGIN_ID_COMPONENT_LEN { + break; + } + if ch.is_ascii_alphanumeric() { + sanitized.push(ch.to_ascii_lowercase()); + previous_separator = false; + } else if !previous_separator { + sanitized.push('_'); + previous_separator = true; + } + } + + let sanitized = sanitized.trim_matches('_').to_string(); + if sanitized.is_empty() { + "plugin".to_string() + } else { + sanitized + } +} + +fn adapter_port_error(message: String) -> PortError { + PortError::new(PortErrorKind::InvalidRequest, message) +} + +fn invalid_validation(field: &str, code: &str, message: &str) -> PluginConfigValidationState { + PluginConfigValidationState { + status: PluginConfigValidationStatus::Invalid, + issues: vec![PluginConfigValidationIssue { + field: field.to_string(), + code: code.to_string(), + message: message.to_string(), + }], + } +} + +fn audit_ref(envelope: &PluginDispatchEnvelope) -> PluginAuditRef { + PluginAuditRef { + correlation_id: envelope.correlation_id.clone(), + event_id: Some(envelope.event_id.clone()), + } +} diff --git a/src/crates/adapters/dsh-adapter/tests/dsh_source_adapter.rs b/src/crates/adapters/dsh-adapter/tests/dsh_source_adapter.rs new file mode 100644 index 000000000..7eb297339 --- /dev/null +++ b/src/crates/adapters/dsh-adapter/tests/dsh_source_adapter.rs @@ -0,0 +1,522 @@ +use bitfun_dsh_adapter::load_dsh_package_adapter; +use bitfun_plugin_runtime_client::DefaultPluginRuntimeClient; +use bitfun_product_domains::plugin_source::{ + PluginPackageFile, PluginPackageInput, PluginPackageManifest, PluginPackageSourceIdentity, + PluginTrustDecision, PluginTrustStore, +}; +use bitfun_runtime_ports::{ + PluginCapabilityRef, PluginDispatchEnvelope, PluginOwnerKind, PluginOwnerRef, + PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeEpochs, PluginRuntimeReadRequest, + PluginRuntimeUnavailableReason, PluginSourceKind, PluginStatusKind, PluginTrustLevel, +}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const BUNDLE_PACKAGE_JSON: &str = r#"{ + "name": "acme-dsh-bundle", + "version": "1.0.0", + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +}"#; + +const BUNDLE_PATCH: &str = r#" +- insert: + - id: acme.tool + disabled: !!js process.platform === 'win32' + config: + command: [node, tool.js] + - id: acme.agent + name: group + group: true + config: + - id: acme.agent.inner + name: acme-agent-inner +"#; + +const PROFILE_PACKAGE_JSON: &str = r#"{ + "name": "acme-dsh-profile", + "version": "1.0.0", + "dsh": { "profile": { "bundles": ["@acme/dsh-base", "@acme/dsh-web"] } } +}"#; + +const BUNDLE_AND_PROFILE_PACKAGE_JSON: &str = r#"{ + "name": "acme-dsh-combined", + "version": "1.0.0", + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "profile": { "bundles": ["@acme/dsh-base", "@acme/dsh-web"] } + } +}"#; + +fn build_input(files: &[(&str, &str)]) -> PluginPackageInput { + let manifest = PluginPackageManifest { + schema_version: 1, + id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "dsh_compatible".to_string(), + files: files + .iter() + .map(|(path, content)| PluginPackageFile { + path: (*path).to_string(), + sha256: format!("sha256:{}", hex::encode(Sha256::digest(content.as_bytes()))), + }) + .collect(), + }; + let source = PluginPackageSourceIdentity { + package_id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "dsh_compatible".to_string(), + source_path: "/managed/acme.dsh".to_string(), + content_hash: manifest.content_hash().expect("manifest content hash"), + }; + let file_map = files + .iter() + .map(|(path, content)| ((*path).to_string(), content.as_bytes().to_vec())) + .collect::>(); + PluginPackageInput::new(manifest, source, file_map).expect("valid package input") +} + +fn bundle_input() -> PluginPackageInput { + build_input(&[ + ("package.json", BUNDLE_PACKAGE_JSON), + ("cordis.patch.yml", BUNDLE_PATCH), + ]) +} + +fn read_request(plugin_ids: Vec) -> PluginRuntimeReadRequest { + PluginRuntimeReadRequest { + request_id: "dsh-read".to_string(), + project_domain_id: "project".to_string(), + workspace_id: "workspace".to_string(), + plugin_ids, + include_config_validation: true, + epochs: PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch: 1, + policy_epoch: 0, + tool_registry_epoch: None, + }, + } +} + +fn dispatch_envelope(source: bitfun_runtime_ports::PluginSourceRef) -> PluginDispatchEnvelope { + PluginDispatchEnvelope { + envelope_version: 1, + event_id: "dispatch-1".to_string(), + event_type: "plugin.dispatch.requested".to_string(), + event_version: "v1".to_string(), + project_domain_id: "project".to_string(), + workspace_id: "workspace".to_string(), + extension_point_id: "tool".to_string(), + declared_capability: PluginCapabilityRef { + capability_id: "dsh.entry".to_string(), + owner: PluginOwnerRef { + kind: PluginOwnerKind::ExtensionContract, + id: "dsh".to_string(), + }, + }, + source, + correlation_id: "correlation".to_string(), + causation_id: None, + idempotency_key: "idem-1".to_string(), + deadline_ms: 30_000, + epochs: PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch: 1, + policy_epoch: 0, + tool_registry_epoch: None, + }, + payload_ref: None, + } +} + +fn activation_authority( + input: &PluginPackageInput, +) -> bitfun_product_domains::plugin_source::PluginActivationAuthority { + let source = input.clone().into_parts().1; + let mut trust = PluginTrustStore::new(1); + trust + .apply_decision( + "project", + "workspace", + source.clone(), + PluginTrustDecision::ApproveSource, + 1_720_000_000, + ) + .expect("approve dsh source"); + trust + .activate("project", "workspace", source.clone(), 1_720_000_001) + .expect("activate dsh source"); + trust + .activation_authority("project", "workspace", &source) + .expect("dsh activation authority") +} + +#[tokio::test] +async fn bundle_entries_project_as_projection_only_sources() { + let (adapter, dispatch_targets) = + load_dsh_package_adapter(bundle_input(), None, 1_720_000_001).expect("dsh adapter"); + assert!( + dispatch_targets.is_empty(), + "dsh entries have no tool targets" + ); + + let client = DefaultPluginRuntimeClient::new(adapter); + assert_eq!( + client.availability(), + PluginRuntimeAvailability::ProjectionOnly { + reason: PluginRuntimeUnavailableReason::HostUnavailable + } + ); + + let read = client + .read_plugins(read_request(Vec::new())) + .await + .expect("read plugins"); + assert_eq!(read.sources.len(), 3, "two top-level + one nested entry"); + assert!(read + .sources + .iter() + .all(|source| source.source_kind == PluginSourceKind::DeepSeekHarnessCompatible)); + assert!(read + .sources + .iter() + .all(|source| source.trust_level == PluginTrustLevel::Unknown)); + assert!(read + .plugin_statuses + .iter() + .all(|status| status.status == PluginStatusKind::TrustRequired)); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_entry_projection_only")); + + let source = read.sources[0].clone(); + let response = client + .dispatch(dispatch_envelope(source)) + .await + .expect("dispatch projection"); + assert!( + response.effects.is_empty(), + "static adapter produces no effects" + ); + assert_eq!( + response.plugin_statuses[0].status, + PluginStatusKind::TrustRequired + ); +} + +#[tokio::test] +async fn profile_bundles_project_as_projection_only_sources() { + let input = build_input(&[("package.json", PROFILE_PACKAGE_JSON)]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).expect("dsh adapter"); + let client = DefaultPluginRuntimeClient::new(adapter); + + let read = client + .read_plugins(read_request(Vec::new())) + .await + .expect("read plugins"); + assert_eq!(read.sources.len(), 2); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.profile_bundle_projection_only")); +} + +#[tokio::test] +async fn invalid_profile_names_are_diagnosed_and_duplicate_layers_are_preserved() { + let package_json = r#"{ + "name": "acme-dsh-profile", + "version": "1.0.0", + "dsh": { + "profile": { + "bundles": ["@acme/dsh-base", " ", "@acme/dsh-base"] + } + } + }"#; + let input = build_input(&[("package.json", package_json)]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + assert_eq!( + read.sources.len(), + 3, + "two ordered layer references and one invalid projection" + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.profile_bundle_invalid")); + assert_eq!( + read.plugin_statuses + .iter() + .filter(|status| status.status == PluginStatusKind::InvalidConfig) + .count(), + 1 + ); + let valid_sources = read + .plugin_statuses + .iter() + .filter(|status| status.status == PluginStatusKind::TrustRequired) + .map(|status| &status.source) + .collect::>(); + assert_eq!(valid_sources.len(), 2); + assert_ne!(valid_sources[0].plugin_id, valid_sources[1].plugin_id); +} + +#[tokio::test] +async fn package_can_project_bundle_and_profile_roles_together() { + let input = build_input(&[ + ("package.json", BUNDLE_AND_PROFILE_PACKAGE_JSON), + ("cordis.patch.yml", BUNDLE_PATCH), + ]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + + let read = client + .read_plugins(read_request(Vec::new())) + .await + .expect("read combined dsh package"); + assert_eq!(read.sources.len(), 5, "three patch rows plus two bundles"); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_entry_projection_only")); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.profile_bundle_projection_only")); +} + +#[tokio::test] +async fn read_plugins_filters_by_plugin_id() { + let (adapter, _) = load_dsh_package_adapter(bundle_input(), None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let all = client.read_plugins(read_request(Vec::new())).await.unwrap(); + let target = all.sources[0].plugin_id.clone(); + + let filtered = client + .read_plugins(read_request(vec![target.clone()])) + .await + .unwrap(); + assert_eq!(filtered.sources.len(), 1); + assert_eq!(filtered.sources[0].plugin_id, target); +} + +#[tokio::test] +async fn missing_package_json_produces_invalid_projection() { + let input = build_input(&[("cordis.patch.yml", BUNDLE_PATCH)]); + let expected_source = input.clone().into_parts().1; + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + assert_eq!(read.sources.len(), 1); + assert_eq!(read.sources[0].version.as_deref(), Some("1.0.0")); + assert_eq!(read.sources[0].content_hash, expected_source.content_hash); + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.package_json_missing")); +} + +#[tokio::test] +async fn bundle_declaration_requires_an_explicit_patch_path() { + let package_json = r#"{ + "name": "acme-dsh-bundle", + "version": "1.0.0", + "dsh": { "bundle": {} } + }"#; + let input = build_input(&[ + ("package.json", package_json), + ("cordis.patch.yml", BUNDLE_PATCH), + ]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + + assert_eq!(read.sources.len(), 1); + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_patch_missing")); +} + +#[tokio::test] +async fn bundle_patch_path_uses_package_relative_normalization() { + let package_json = r#"{ + "name": "acme-dsh-bundle", + "version": "1.0.0", + "dsh": { "bundle": { "patch": "./config/../cordis.patch.yml" } } + }"#; + let input = build_input(&[ + ("package.json", package_json), + ("cordis.patch.yml", BUNDLE_PATCH), + ]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + + assert_eq!(read.sources.len(), 3); + assert!(read + .plugin_statuses + .iter() + .all(|status| status.status == PluginStatusKind::TrustRequired)); +} + +#[tokio::test] +async fn package_without_dsh_field_produces_invalid_projection() { + let input = build_input(&[("package.json", r#"{ "name": "plain" }"#)]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.package_no_dsh_field")); +} + +#[tokio::test] +async fn unsupported_client_only_package_reports_its_actual_role() { + let input = build_input(&[( + "package.json", + r#"{ + "name": "client-only", + "dsh": { + "client": { "platform": "web", "inject": [] } + } + }"#, + )]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.package_no_supported_role")); + assert!(!read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.package_no_dsh_field")); +} + +#[tokio::test] +async fn bundle_with_missing_patch_produces_invalid_projection() { + let input = build_input(&[("package.json", BUNDLE_PACKAGE_JSON)]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_patch_missing")); +} + +#[tokio::test] +async fn empty_bundle_patch_produces_invalid_projection() { + let input = build_input(&[ + ("package.json", BUNDLE_PACKAGE_JSON), + ("cordis.patch.yml", "# comments only\n"), + ]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + assert_eq!( + read.plugin_statuses[0].status, + PluginStatusKind::InvalidConfig + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_no_entries")); +} + +#[tokio::test] +async fn bundle_entries_without_stable_ids_are_not_silently_dropped() { + let patch = r#" +- insert: + - id: valid-entry + - id: "" +"#; + let input = build_input(&[ + ("package.json", BUNDLE_PACKAGE_JSON), + ("cordis.patch.yml", patch), + ]); + let (adapter, _) = load_dsh_package_adapter(input, None, 1_720_000_001).unwrap(); + let client = DefaultPluginRuntimeClient::new(adapter); + let read = client.read_plugins(read_request(Vec::new())).await.unwrap(); + + assert_eq!( + read.sources.len(), + 2, + "one valid and one invalid projection" + ); + assert!(read + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "dsh.bundle_entry_invalid")); +} + +#[tokio::test] +async fn invalid_package_cannot_be_activated() { + let input = build_input(&[("package.json", r#"{ "name": "plain" }"#)]); + let authority = activation_authority(&input); + let error = load_dsh_package_adapter(input, Some(authority), 1_720_000_001) + .err() + .expect("invalid dsh package must not activate"); + assert!(error.to_string().contains("invalid dsh package projection")); +} + +#[tokio::test] +async fn wrong_adapter_is_rejected() { + let manifest = PluginPackageManifest { + schema_version: 1, + id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "opencode_compatible".to_string(), + files: vec![PluginPackageFile { + path: "package.json".to_string(), + sha256: format!( + "sha256:{}", + hex::encode(Sha256::digest(BUNDLE_PACKAGE_JSON.as_bytes())) + ), + }], + }; + let source = PluginPackageSourceIdentity { + package_id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "opencode_compatible".to_string(), + source_path: "/managed/acme.dsh".to_string(), + content_hash: manifest.content_hash().unwrap(), + }; + let files = BTreeMap::from([( + "package.json".to_string(), + BUNDLE_PACKAGE_JSON.as_bytes().to_vec(), + )]); + let input = PluginPackageInput::new(manifest, source, files).unwrap(); + + let error = load_dsh_package_adapter(input, None, 1) + .err() + .expect("wrong adapter must fail"); + assert!(error.to_string().contains("not dsh-compatible")); +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 61af6a74c..46235421e 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -118,6 +118,7 @@ bitfun-runtime-services = { path = "../../execution/runtime-services", optional # Reviewed product-full plugin composition root. bitfun-opencode-adapter = { path = "../../adapters/opencode-adapter", optional = true } +bitfun-dsh-adapter = { path = "../../adapters/dsh-adapter", optional = true } bitfun-claude-code-adapter = { path = "../../adapters/claude-code-adapter", optional = true } bitfun-codex-adapter = { path = "../../adapters/codex-adapter", optional = true } bitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client", optional = true } @@ -253,6 +254,7 @@ external-sources = [ "mcp-runtime", "script-tool-runtime", "dep:bitfun-opencode-adapter", + "dep:bitfun-dsh-adapter", "dep:bitfun-claude-code-adapter", "dep:bitfun-codex-adapter", "dep:bitfun-external-sources", diff --git a/src/crates/assembly/core/src/plugin_runtime.rs b/src/crates/assembly/core/src/plugin_runtime.rs index a366d9d39..af1bf8e1d 100644 --- a/src/crates/assembly/core/src/plugin_runtime.rs +++ b/src/crates/assembly/core/src/plugin_runtime.rs @@ -1,14 +1,15 @@ //! Managed plugin composition. //! -//! This is the only plugin-runtime root that selects the OpenCode-compatible -//! adapter and PluginRuntimeClient. Workspace identity here scopes source, +//! This is the only plugin-runtime root that selects managed-package ecosystem +//! adapters and PluginRuntimeClient. Workspace identity here scopes source, //! approval, and logical client state; it is not a process ownership boundary. //! This module projects candidates for product surfaces; it does not register //! tools or execute plugin code. use async_trait::async_trait; +use bitfun_dsh_adapter::load_dsh_package_adapter; use bitfun_opencode_adapter::load_opencode_package_adapter; -use bitfun_plugin_runtime_client::DefaultPluginRuntimeClient; +use bitfun_plugin_runtime_client::{DefaultPluginRuntimeClient, PluginRuntimeAdapter}; use bitfun_product_domains::plugin_source::{ PluginActivationAuthority, PluginPackageInput, PluginPackageSourceIdentity, }; @@ -16,8 +17,8 @@ use bitfun_runtime_ports::{ PluginCapabilityRef, PluginDispatchEnvelope, PluginEffectCandidatePayload, PluginPermissionGate, PluginResponseEnvelope, PluginRiskLevel, PluginRuntimeAvailability, PluginRuntimeBinding, PluginRuntimeClient, PluginRuntimeEpochs, PluginRuntimeReadRequest, - PluginRuntimeReadResponse, PluginSourceRef, PluginTargetRef, PortError, PortErrorKind, - PortResult, + PluginRuntimeReadResponse, PluginSourceRef, PluginStatusKind, PluginTargetRef, PortError, + PortErrorKind, PortResult, }; use bitfun_services_integrations::plugin_source::{ ManagedPluginSourceError, ManagedPluginSourceIssue, ManagedPluginSourceService, @@ -28,6 +29,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; const PREVIEW_PROJECT_ID: &str = "managed-plugin-preview"; const PREVIEW_WORKSPACE_ID: &str = "managed-plugin-preview"; +const DSH_MANIFEST_ADAPTER_ID: &str = "dsh_compatible"; +const OPENCODE_MANIFEST_ADAPTER_ID: &str = "opencode_compatible"; type PluginDispatchTarget = ( PluginSourceRef, String, @@ -35,6 +38,29 @@ type PluginDispatchTarget = ( Vec<(PluginTargetRef, PluginRiskLevel)>, ); +fn load_package_adapter( + input: PluginPackageInput, + activation: Option, + observed_at_ms: u64, +) -> Result<(Arc, Vec), ManagedPluginSourceError> { + let (adapter, package_id) = { + let (manifest, source, _files) = input.clone().into_parts(); + (manifest.adapter, source.package_id) + }; + match adapter.as_str() { + OPENCODE_MANIFEST_ADAPTER_ID => { + load_opencode_package_adapter(input, activation, observed_at_ms) + .map_err(|error| invalid_package(&package_id, error.to_string())) + } + DSH_MANIFEST_ADAPTER_ID => load_dsh_package_adapter(input, activation, observed_at_ms) + .map_err(|error| invalid_package(&package_id, error.to_string())), + other => Err(invalid_package( + &package_id, + format!("unsupported managed package adapter: {other}"), + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ManagedPluginCandidateView { pub entry_id: String, @@ -57,6 +83,11 @@ pub struct ManagedPluginActivationView { pub diagnostics: Vec, } +struct ManagedPluginPreview { + view: ManagedPluginActivationView, + activation_config_valid: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ManagedPluginDeactivationResult { Deactivated { @@ -111,25 +142,41 @@ async fn preview_with_service( workspace: &Path, package_id: &str, ) -> Result { + Ok(project_preview_with_service(service, workspace, package_id) + .await? + .view) +} + +async fn project_preview_with_service( + service: Arc, + workspace: &Path, + package_id: &str, +) -> Result { let input = service.load_package(workspace, package_id).await?; let source = input.clone().into_parts().1; - let (adapter, dispatch_targets) = load_opencode_package_adapter(input, None, current_time_ms()) - .map_err(|error| invalid_package(package_id, error.to_string()))?; + let (adapter, dispatch_targets) = load_package_adapter(input, None, current_time_ms())?; let binding = PluginRuntimeBinding::client(Arc::new(DefaultPluginRuntimeClient::new(adapter))); let response = binding .as_client() .read_plugins(read_request(PREVIEW_PROJECT_ID, PREVIEW_WORKSPACE_ID, 1)) .await .map_err(|error| unavailable(package_id, error.to_string()))?; + let activation_config_valid = response + .plugin_statuses + .iter() + .all(|status| status.status != PluginStatusKind::InvalidConfig); let candidates = preview_candidates(&dispatch_targets); - Ok(project_view( - source, - false, - None, - !dispatch_targets.is_empty(), - response, - candidates, - )) + Ok(ManagedPluginPreview { + view: project_view( + source, + false, + None, + !dispatch_targets.is_empty(), + response, + candidates, + ), + activation_config_valid, + }) } async fn activate_with_service( @@ -144,14 +191,23 @@ async fn activate_with_service( "activation requires the exact content hash from the preview".to_string(), ) })?; - let preview = preview_with_service(Arc::clone(&service), workspace, package_id).await?; + let ManagedPluginPreview { + view: preview, + activation_config_valid, + } = project_preview_with_service(Arc::clone(&service), workspace, package_id).await?; if preview.content_hash != expected_content_hash { return Err(invalid_package( package_id, "activation confirmation does not match the current package content".to_string(), )); } - if !preview.provider_candidates_supported { + if preview.adapter == DSH_MANIFEST_ADAPTER_ID && !activation_config_valid { + return Err(invalid_package( + package_id, + "the package projection contains invalid configuration".to_string(), + )); + } + if preview.adapter == OPENCODE_MANIFEST_ADAPTER_ID && !preview.provider_candidates_supported { return Err(invalid_package( package_id, "the package contains no supported OpenCode custom tool declaration".to_string(), @@ -252,6 +308,7 @@ async fn project_activated( authority.clone().into_parts(); let (binding, dispatch_targets) = activated_binding(service, workspace, package_id, input, authority)?; + let provider_candidates_supported = !dispatch_targets.is_empty(); let client = binding.as_client(); let read = client .read_plugins(read_request( @@ -293,10 +350,15 @@ async fn project_activated( } diagnostics.sort(); diagnostics.dedup(); - Ok( - project_view(source, true, Some(activation_epoch), true, read, candidates) - .with_diagnostics(diagnostics), + Ok(project_view( + source, + true, + Some(activation_epoch), + provider_candidates_supported, + read, + candidates, ) + .with_diagnostics(diagnostics)) } fn preview_candidates( @@ -324,8 +386,7 @@ fn activated_binding( authority: PluginActivationAuthority, ) -> Result<(PluginRuntimeBinding, Vec), ManagedPluginSourceError> { let (adapter, dispatch_targets) = - load_opencode_package_adapter(input, Some(authority.clone()), current_time_ms()) - .map_err(|error| invalid_package(package_id, error.to_string()))?; + load_package_adapter(input, Some(authority.clone()), current_time_ms())?; let client: Arc = Arc::new(DefaultPluginRuntimeClient::new(adapter)); Ok(( PluginRuntimeBinding::client(Arc::new(ActivationGatedPluginRuntimeClient { @@ -542,6 +603,7 @@ impl PluginRuntimeClient for ActivationGatedPluginRuntimeClient { #[cfg(test)] mod tests { use super::*; + use bitfun_product_domains::plugin_source::{PluginPackageFile, PluginPackageManifest}; use bitfun_services_integrations::plugin_source::ManagedPluginTrustDecision; use sha2::{Digest, Sha256}; use std::fs; @@ -602,30 +664,41 @@ export const WorkspaceToolsPlugin: Plugin = async () => ({ } async fn with_source(plugin_source: &str) -> Self { + Self::with_managed_package( + "opencode_compatible", + &[(".opencode/plugins/workspace-tools.ts", plugin_source)], + ) + .await + } + + async fn with_managed_package(adapter: &str, files: &[(&str, &str)]) -> Self { let temp = tempfile::tempdir().expect("tempdir"); let workspace = temp.path().join("workspace"); let user = temp.path().join("user"); let package = workspace.join(".bitfun/plugins/acme.demo"); - let source_path = package.join(".opencode/plugins/workspace-tools.ts"); - fs::create_dir_all(source_path.parent().expect("source parent")) - .expect("create package"); + let source_path = package.join(files[0].0); fs::create_dir_all(user.join("plugins")).expect("create user plugins"); - fs::write(&source_path, plugin_source).expect("write plugin source"); - let file_hash = format!( - "sha256:{}", - hex::encode(Sha256::digest(plugin_source.as_bytes())) - ); + let mut manifest_files = Vec::new(); + for (relative_path, contents) in files { + let path = package.join(relative_path); + fs::create_dir_all(path.parent().expect("source parent")).expect("create package"); + fs::write(&path, contents).expect("write plugin source"); + manifest_files.push(serde_json::json!({ + "path": relative_path, + "sha256": format!( + "sha256:{}", + hex::encode(Sha256::digest(contents.as_bytes())) + ) + })); + } fs::write( package.join("bitfun.plugin.json"), serde_json::to_vec_pretty(&serde_json::json!({ "schemaVersion": 1, "id": "acme.demo", "version": "1.0.0", - "adapter": "opencode_compatible", - "files": [{ - "path": ".opencode/plugins/workspace-tools.ts", - "sha256": file_hash - }] + "adapter": adapter, + "files": manifest_files })) .expect("serialize manifest"), ) @@ -978,4 +1051,140 @@ export const WorkspaceToolsPlugin: Plugin = async () => ({ .expect_err("revoked dispatch result must be discarded"); assert_eq!(error.kind, PortErrorKind::NotAvailable); } + + #[tokio::test] + async fn dsh_packages_route_to_the_dsh_adapter() { + let manifest = PluginPackageManifest { + schema_version: 1, + id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "dsh_compatible".to_string(), + files: vec![PluginPackageFile { + path: "package.json".to_string(), + sha256: format!( + "sha256:{}", + hex::encode(Sha256::digest( + br#"{ "dsh": { "profile": { "bundles": ["@acme/dsh-base"] } } }"# + )) + ), + }], + }; + let source = PluginPackageSourceIdentity { + package_id: "acme.dsh".to_string(), + version: "1.0.0".to_string(), + adapter: "dsh_compatible".to_string(), + source_path: "/managed/acme.dsh".to_string(), + content_hash: manifest.content_hash().expect("content hash"), + }; + let files = std::collections::BTreeMap::from([( + "package.json".to_string(), + br#"{ "dsh": { "profile": { "bundles": ["@acme/dsh-base"] } } }"#.to_vec(), + )]); + let input = PluginPackageInput::new(manifest, source, files).expect("dsh input"); + + let (adapter, dispatch_targets) = + load_package_adapter(input, None, current_time_ms()).expect("route dsh adapter"); + assert_eq!(adapter.adapter_id(), "dsh-compatible"); + assert!(dispatch_targets.is_empty()); + } + + #[tokio::test] + async fn valid_dsh_projection_can_activate_without_tool_candidates() { + let fixture = Fixture::with_managed_package( + "dsh_compatible", + &[( + "package.json", + r#"{ "dsh": { "profile": { "bundles": ["@acme/dsh-base"] } } }"#, + )], + ) + .await; + let preview = preview_with_service( + Arc::clone(&fixture.service), + &fixture.workspace, + "acme.demo", + ) + .await + .expect("preview dsh package"); + assert!(!preview.provider_candidates_supported); + assert!(!preview.entry_ids.is_empty()); + + let activated = activate_with_service( + Arc::clone(&fixture.service), + &fixture.workspace, + "acme.demo", + Some(&preview.content_hash), + ) + .await + .expect("activate valid dsh projection"); + assert!(activated.activated); + assert!(!activated.provider_candidates_supported); + assert!(!activated.permission_required); + } + + #[tokio::test] + async fn invalid_dsh_projection_does_not_persist_activation() { + let fixture = Fixture::with_managed_package( + "dsh_compatible", + &[("package.json", r#"{ "name": "plain-package" }"#)], + ) + .await; + let preview = preview_with_service( + Arc::clone(&fixture.service), + &fixture.workspace, + "acme.demo", + ) + .await + .expect("preview invalid dsh package"); + let activation_epoch_before = fixture + .service + .refresh(&fixture.workspace) + .await + .activation_epoch; + + let error = activate_with_service( + Arc::clone(&fixture.service), + &fixture.workspace, + "acme.demo", + Some(&preview.content_hash), + ) + .await + .expect_err("invalid dsh package must not remain active"); + assert!(matches!( + error, + ManagedPluginSourceError::PackageInvalid { .. } + )); + let snapshot = fixture.service.refresh(&fixture.workspace).await; + assert!(!snapshot.packages[0].activated); + assert_eq!(snapshot.activation_epoch, activation_epoch_before); + } + + #[tokio::test] + async fn unsupported_managed_package_adapter_is_rejected() { + let manifest = PluginPackageManifest { + schema_version: 1, + id: "acme.unknown".to_string(), + version: "1.0.0".to_string(), + adapter: "unknown_compatible".to_string(), + files: vec![PluginPackageFile { + path: "package.json".to_string(), + sha256: format!("sha256:{}", hex::encode(Sha256::digest(b"{}"))), + }], + }; + let source = PluginPackageSourceIdentity { + package_id: "acme.unknown".to_string(), + version: "1.0.0".to_string(), + adapter: "unknown_compatible".to_string(), + source_path: "/managed/acme.unknown".to_string(), + content_hash: manifest.content_hash().expect("content hash"), + }; + let files = + std::collections::BTreeMap::from([("package.json".to_string(), b"{}".to_vec())]); + let input = PluginPackageInput::new(manifest, source, files).expect("unknown input"); + let error = load_package_adapter(input, None, current_time_ms()) + .err() + .expect("unsupported adapter must fail"); + assert!(error + .to_string() + .contains("unsupported managed package adapter")); + } } diff --git a/src/crates/contracts/runtime-ports/src/plugin.rs b/src/crates/contracts/runtime-ports/src/plugin.rs index 4883d2764..674d8edf6 100644 --- a/src/crates/contracts/runtime-ports/src/plugin.rs +++ b/src/crates/contracts/runtime-ports/src/plugin.rs @@ -67,6 +67,7 @@ pub type PluginRuntimeAvailability = ExtensionCapabilityAvailability; pub enum PluginSourceKind { LocalPath, OpenCodeCompatible, + DeepSeekHarnessCompatible, RemoteRegistry, BitFunNative, } diff --git a/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs index e2c62277e..6bda80f9d 100644 --- a/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs +++ b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs @@ -32,6 +32,21 @@ fn source_ref() -> PluginSourceRef { } } +#[test] +fn deep_seek_harness_source_kind_has_a_stable_wire_name() { + let mut source = source_ref(); + source.source_kind = PluginSourceKind::DeepSeekHarnessCompatible; + + let json = serde_json::to_value(&source).expect("serialize dsh source"); + assert_eq!(json["sourceKind"], "deep_seek_harness_compatible"); + + let roundtrip: PluginSourceRef = serde_json::from_value(json).expect("deserialize dsh source"); + assert_eq!( + roundtrip.source_kind, + PluginSourceKind::DeepSeekHarnessCompatible + ); +} + fn capability_ref() -> PluginCapabilityRef { PluginCapabilityRef { capability_id: "tools.provider".to_string(),