From 208f99dfe6da7586e8bde728df22d06b05384cb0 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:42:55 +0800 Subject: [PATCH 1/6] feat(sdk): propagate lifecycle errors Align the Rust author trait with the existing WIT widget-error result so start and event callbacks can report business rejections without being swallowed or misclassified as runtime traps. Update templates and reference fixtures, and prove both callback paths preserve rejection semantics while the host remains alive. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-sdk --locked; RUSTC_WRAPPER= cargo test -p floatile-runtime --test security sdk_lifecycle_errors_reach_the_host_as_rejections --locked; RUSTC_WRAPPER= cargo check -p floatile-sdk --target wasm32-wasip2 --locked; RUSTC_WRAPPER= cargo test -p floatile-cli project --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test sdk_package generated_project_resolves_only_packaged_sdk_sources --locked Unverified: lifecycle rejection rendering in the interactive shell on Windows, macOS, X11, and Wayland --- crates/floatile-cli/src/project.rs | 11 +++-- crates/floatile-runtime/tests/security.rs | 46 +++++++++++++++++++++ crates/floatile-sdk/src/build.rs | 16 +++++-- crates/floatile-sdk/src/export.rs | 5 +-- crates/floatile-sdk/src/lib.rs | 1 + crates/floatile-sdk/src/widget.rs | 12 ++++-- docs/plugin-sdk/sdk-developer-experience.md | 8 +++- plugins/ai-balance-wasm/src/lib.rs | 8 ++-- plugins/clock-wasm/src/lib.rs | 8 ++-- plugins/evil-wasm/src/lib.rs | 14 +++++-- 10 files changed, 104 insertions(+), 25 deletions(-) diff --git a/crates/floatile-cli/src/project.rs b/crates/floatile-cli/src/project.rs index 06b4725..e299d2d 100644 --- a/crates/floatile-cli/src/project.rs +++ b/crates/floatile-cli/src/project.rs @@ -260,7 +260,8 @@ floatile-sdk = "0.1" serde = { version = "1", features = ["derive"] } "# .to_owned(); - let lib_rs = r#"use floatile_sdk::{Context, LogLevel, State, Widget, WidgetEvent, view}; + let lib_rs = + r#"use floatile_sdk::{Context, LogLevel, State, Widget, WidgetEvent, WidgetResult, view}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, State)] @@ -279,12 +280,14 @@ impl Widget for MyWidget { view::column(vec![view::text_bind("$.message")]) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { let _ = ctx.timer().schedule(1000); + Ok(()) } - fn event(&mut self, _event: WidgetEvent, ctx: &mut Context) { + fn event(&mut self, _event: WidgetEvent, ctx: &mut Context) -> WidgetResult { let _ = ctx.log(LogLevel::Info, "event received"); + Ok(()) } } @@ -296,7 +299,7 @@ pub fn __floatile_ftui_json() -> String { floatile_sdk::build::build_ftui::(std::collections::BTreeMap::new()) } "# - .to_owned(); + .to_owned(); let build_ftui_rs = r#"fn main() { print!("{}", clock::__floatile_ftui_json()); } diff --git a/crates/floatile-runtime/tests/security.rs b/crates/floatile-runtime/tests/security.rs index 66b12b1..82a351e 100644 --- a/crates/floatile-runtime/tests/security.rs +++ b/crates/floatile-runtime/tests/security.rs @@ -175,6 +175,52 @@ async fn denied_capability_persists_audit_and_host_survives() { handle2.shutdown().await.expect("新实例 shutdown 正常"); } +/// Rust SDK lifecycle errors must cross WIT as business rejections instead of +/// being swallowed or misclassified as traps. +#[tokio::test(flavor = "multi_thread")] +async fn sdk_lifecycle_errors_reach_the_host_as_rejections() { + let manager = WidgetManager::new().unwrap(); + let start_error = spawn_evil( + &manager, + 81, + evil_grants_none(81), + json!({"mode": "guest-start-error"}), + ); + let result = start_error.start().await; + assert!( + matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) if message.contains("fixture start rejection")), + "SDK start error should remain a guest rejection, got {result:?}" + ); + + let event_error = spawn_evil( + &manager, + 82, + evil_grants_none(82), + json!({"mode": "guest-event-error"}), + ); + event_error + .start() + .await + .expect("event fixture should start"); + let result = event_error + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await; + assert!( + matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) if message.contains("fixture event rejection")), + "SDK event error should remain a guest rejection, got {result:?}" + ); + + let survivor = spawn_evil(&manager, 83, evil_grants_none(83), json!({"mode": "deny"})); + survivor + .start() + .await + .expect("host should survive guest rejection"); + survivor.shutdown().await.expect("survivor should stop"); +} + /// 正式 WIT Operation:guest submit → metadata completion → typed one-shot take → State 更新。 #[tokio::test(flavor = "multi_thread")] async fn storage_operation_round_trips_through_guest_contract() { diff --git a/crates/floatile-sdk/src/build.rs b/crates/floatile-sdk/src/build.rs index 15efa4d..0284440 100644 --- a/crates/floatile-sdk/src/build.rs +++ b/crates/floatile-sdk/src/build.rs @@ -46,8 +46,12 @@ mod tests { fn view(_: &S) -> crate::view::View { crate::view::column(vec![crate::view::text_bind("$.time")]) } - fn start(&mut self, _: &mut Context) {} - fn event(&mut self, _: WidgetEvent, _: &mut Context) {} + fn start(&mut self, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } + fn event(&mut self, _: WidgetEvent, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } } impl Default for W { fn default() -> Self { @@ -62,8 +66,12 @@ mod tests { fn view(_: &S) -> crate::view::View { crate::view::button("Go") } - fn start(&mut self, _: &mut Context) {} - fn event(&mut self, _: WidgetEvent, _: &mut Context) {} + fn start(&mut self, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } + fn event(&mut self, _: WidgetEvent, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } } impl Default for WButton { fn default() -> Self { diff --git a/crates/floatile-sdk/src/export.rs b/crates/floatile-sdk/src/export.rs index f6ac12b..9a4ec9a 100644 --- a/crates/floatile-sdk/src/export.rs +++ b/crates/floatile-sdk/src/export.rs @@ -52,8 +52,7 @@ macro_rules! impl_export_widget { ); } let mut ctx = $crate::Context::new(); - self.widget.borrow_mut().start(&mut ctx); - Ok(()) + self.widget.borrow_mut().start(&mut ctx) } fn handle_event( @@ -69,7 +68,7 @@ macro_rules! impl_export_widget { <<$widget as $crate::Widget>::Event as $crate::FromWidgetEvent>::from_widget_event(event) { let mut ctx = $crate::Context::new(); - self.widget.borrow_mut().event(ev, &mut ctx); + self.widget.borrow_mut().event(ev, &mut ctx)?; } Ok(()) } diff --git a/crates/floatile-sdk/src/lib.rs b/crates/floatile-sdk/src/lib.rs index 7c57853..788f216 100644 --- a/crates/floatile-sdk/src/lib.rs +++ b/crates/floatile-sdk/src/lib.rs @@ -59,3 +59,4 @@ pub use floatile_ui_schema::{JsonSchema, merge_patch, validate_document}; pub use state::State; pub use widget::FromWidgetEvent; pub use widget::Widget; +pub use widget::WidgetResult; diff --git a/crates/floatile-sdk/src/widget.rs b/crates/floatile-sdk/src/widget.rs index ed88db6..934a76e 100644 --- a/crates/floatile-sdk/src/widget.rs +++ b/crates/floatile-sdk/src/widget.rs @@ -4,7 +4,13 @@ //! 导出为 WASM Component。所有 host 能力调用经 `Context` → WIT → Broker 路径。 use crate::view::View; -use crate::{Context, WidgetEvent}; +use crate::{Context, WidgetError, WidgetEvent}; + +/// Result returned by fallible widget lifecycle callbacks. +/// +/// The error crosses the versioned WIT `widget-error` contract, allowing the +/// host to distinguish a guest rejection from traps and resource limits. +pub type WidgetResult = Result<(), WidgetError>; /// 从宿主级 [`WidgetEvent`] 转换为作者定义的事件类型。 /// @@ -36,8 +42,8 @@ pub trait Widget: Sized + Default { type Event: FromWidgetEvent; fn view(state: &Self::State) -> View; fn init(&mut self, _initial: &Self::State) {} - fn start(&mut self, ctx: &mut Context); - fn event(&mut self, event: Self::Event, ctx: &mut Context); + fn start(&mut self, ctx: &mut Context) -> WidgetResult; + fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult; fn stop(&mut self) {} } diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index d34be93..207de63 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -87,11 +87,11 @@ impl Clock { .gap(8) } - fn start(ctx: &mut Context) -> Result<()> { + fn start(ctx: &mut Context) -> WidgetResult { ctx.timer().every("1s", Event::Refresh) } - async fn event(event: Event, ctx: &mut Context) -> Result<()> { + fn event(event: Event, ctx: &mut Context) -> WidgetResult { if matches!(event, Event::Refresh) { let time = ctx.clock().local_time(); ctx.state().update(|state| state.time = time)?; @@ -104,6 +104,10 @@ impl Clock { 公开 API 可以随原型调整,但以下语义必须一致:组件名、State 字段、event 名、Context capability、 错误码、默认预算和生命周期顺序。 +Rust SDK 的 `Widget::start` 与 `Widget::event` 返回 `WidgetResult`。`WidgetError` 会原样穿过 WIT +`widget-error` 交给宿主,宿主将其分类为 guest 业务拒绝;SDK 不得吞掉错误,也不得把它伪装成 trap、 +fuel、超时或内存错误。 + ## 3. 项目模板 ### TypeScript diff --git a/plugins/ai-balance-wasm/src/lib.rs b/plugins/ai-balance-wasm/src/lib.rs index 4bd94f5..aface56 100644 --- a/plugins/ai-balance-wasm/src/lib.rs +++ b/plugins/ai-balance-wasm/src/lib.rs @@ -4,7 +4,7 @@ use floatile_sdk::impl_export_widget; use floatile_sdk::{ Context, FromWidgetEvent, OperationCapability, OperationTerminal, State, Widget, WidgetEvent, - host_http, view, view::View, + WidgetResult, host_http, view, view::View, }; use serde::{Deserialize, Serialize}; @@ -92,11 +92,12 @@ impl Widget for AiBalance { ) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { self.refresh(ctx); + Ok(()) } - fn event(&mut self, event: BalanceEvent, ctx: &mut Context) { + fn event(&mut self, event: BalanceEvent, ctx: &mut Context) -> WidgetResult { match event { BalanceEvent::Refresh => self.refresh(ctx), BalanceEvent::Completed(id, OperationTerminal::Succeeded) => { @@ -111,6 +112,7 @@ impl Widget for AiBalance { mark_error(ctx, "unavailable"); } } + Ok(()) } fn stop(&mut self) {} diff --git a/plugins/clock-wasm/src/lib.rs b/plugins/clock-wasm/src/lib.rs index 32c0647..39af484 100644 --- a/plugins/clock-wasm/src/lib.rs +++ b/plugins/clock-wasm/src/lib.rs @@ -9,7 +9,7 @@ #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; use floatile_sdk::{ - Context, FromWidgetEvent, LogLevel, State, Widget, WidgetEvent, view, view::View, + Context, FromWidgetEvent, LogLevel, State, Widget, WidgetEvent, WidgetResult, view, view::View, }; use serde::{Deserialize, Serialize}; @@ -50,7 +50,7 @@ impl Widget for Clock { view::column(vec![view::text_bind("$.time")]) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { let _ = ctx.log(LogLevel::Info, "clock started"); match ctx.timer().schedule(1000) { Ok(id) => { @@ -60,9 +60,10 @@ impl Widget for Clock { let _ = ctx.log(LogLevel::Warn, &format!("timer denied: {error:?}")); } } + Ok(()) } - fn event(&mut self, event: ClockEvent, ctx: &mut Context) { + fn event(&mut self, event: ClockEvent, ctx: &mut Context) -> WidgetResult { match event { ClockEvent::Start => { let _ = ctx.log(LogLevel::Info, "clock start command received"); @@ -82,6 +83,7 @@ impl Widget for Clock { let _ = ctx.timer().schedule(1000); } } + Ok(()) } fn stop(&mut self) { diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index 74abe12..7649618 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -21,7 +21,7 @@ use floatile_sdk::impl_export_widget; use floatile_sdk::{ Context, FromWidgetEvent, LogLevel, OperationCapability, OperationTerminal, State, Widget, - WidgetEvent, view, view::View, + WidgetError, WidgetEvent, WidgetResult, view, view::View, }; use serde::{Deserialize, Serialize}; @@ -79,7 +79,10 @@ impl Widget for Evil { self.mode = initial.mode.clone(); } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { + if self.mode == "guest-start-error" { + return Err(WidgetError::Rejected("fixture start rejection".into())); + } match self.mode.as_str() { // loop 不进 start:让实例先成功启动,测试再以事件触发无限循环,fuel 才 trap。 "deny" => self.deny_call(ctx), @@ -92,9 +95,13 @@ impl Widget for Evil { let _ = ctx.log(LogLevel::Info, &format!("start mode {mode}")); } } + Ok(()) } - fn event(&mut self, event: Self::Event, ctx: &mut Context) { + fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult { + if self.mode == "guest-event-error" { + return Err(WidgetError::InvalidInput("fixture event rejection".into())); + } match event { EvilEvent::Trigger => match self.mode.as_str() { "loop" => self.loop_forever(), @@ -109,6 +116,7 @@ impl Widget for Evil { } } } + Ok(()) } } From bfa4c603a066ef4ab3214adc0f25dba49b278273 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:44:45 +0800 Subject: [PATCH 2/6] feat(sdk): define the Rust author prelude Introduce a deliberate author-facing import surface separate from raw generated WIT modules. Migrate the project template and reference fixtures to consume it, and add an external API test that compiles the complete Widget, State, View, event, and lifecycle result contract. Refs: PP-M7, FR-PLUGIN-01, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-sdk --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --lib project::tests --locked; RUSTC_WRAPPER= cargo check -p floatile-sdk --target wasm32-wasip2 --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test sdk_package generated_project_resolves_only_packaged_sdk_sources --locked Unverified: public registry publication remains blocked by the licensing ADR; downstream third-party source compatibility is not yet measurable before an SDK release --- crates/floatile-cli/src/project.rs | 5 +-- crates/floatile-sdk/src/lib.rs | 14 +++++++ crates/floatile-sdk/tests/public_api.rs | 41 +++++++++++++++++++++ docs/plugin-sdk/sdk-developer-experience.md | 2 + plugins/ai-balance-wasm/src/lib.rs | 6 +-- plugins/clock-wasm/src/lib.rs | 4 +- plugins/evil-wasm/src/lib.rs | 5 +-- 7 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 crates/floatile-sdk/tests/public_api.rs diff --git a/crates/floatile-cli/src/project.rs b/crates/floatile-cli/src/project.rs index e299d2d..f5e59fb 100644 --- a/crates/floatile-cli/src/project.rs +++ b/crates/floatile-cli/src/project.rs @@ -260,8 +260,7 @@ floatile-sdk = "0.1" serde = { version = "1", features = ["derive"] } "# .to_owned(); - let lib_rs = - r#"use floatile_sdk::{Context, LogLevel, State, Widget, WidgetEvent, WidgetResult, view}; + let lib_rs = r#"use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, State)] @@ -299,7 +298,7 @@ pub fn __floatile_ftui_json() -> String { floatile_sdk::build::build_ftui::(std::collections::BTreeMap::new()) } "# - .to_owned(); + .to_owned(); let build_ftui_rs = r#"fn main() { print!("{}", clock::__floatile_ftui_json()); } diff --git a/crates/floatile-sdk/src/lib.rs b/crates/floatile-sdk/src/lib.rs index 788f216..316f15d 100644 --- a/crates/floatile-sdk/src/lib.rs +++ b/crates/floatile-sdk/src/lib.rs @@ -53,6 +53,20 @@ pub mod state; pub mod view; pub mod widget; +/// Stable author-facing imports for Rust widget projects. +/// +/// Raw generated WIT modules remain available at the crate root for adapter +/// and conformance work, while normal plugins should import this prelude. +pub mod prelude { + pub use crate::view; + pub use crate::view::View; + pub use crate::{ + Context, FromWidgetEvent, LogLevel, OperationCapability, OperationCompletion, + OperationTerminal, State, UiEvent, Widget, WidgetError, WidgetEvent, WidgetResult, + impl_export_widget, + }; +} + pub use context::Context; pub use floatile_sdk_macros::State; pub use floatile_ui_schema::{JsonSchema, merge_patch, validate_document}; diff --git a/crates/floatile-sdk/tests/public_api.rs b/crates/floatile-sdk/tests/public_api.rs new file mode 100644 index 0000000..df84426 --- /dev/null +++ b/crates/floatile-sdk/tests/public_api.rs @@ -0,0 +1,41 @@ +#![allow(dead_code)] + +use floatile_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, State)] +struct PublicState { + message: String, +} + +#[derive(Default)] +struct PublicWidget; + +impl Widget for PublicWidget { + type State = PublicState; + type Event = WidgetEvent; + + fn view(_state: &Self::State) -> View { + view::column(vec![view::text_bind("$.message")]) + } + + fn start(&mut self, _ctx: &mut Context) -> WidgetResult { + Ok(()) + } + + fn event(&mut self, _event: Self::Event, _ctx: &mut Context) -> WidgetResult { + Ok(()) + } +} + +#[test] +fn prelude_exposes_the_complete_author_contract() { + let state = PublicState::initial(); + assert_eq!(state.message, ""); + let _ = PublicWidget::view(&state); + let _ = WidgetError::Rejected("expected".into()); + let _ = WidgetEvent::Ui(UiEvent { + name: "refresh".into(), + payload_json: "{}".into(), + }); +} diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 207de63..fe7cd61 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -107,6 +107,8 @@ impl Clock { Rust SDK 的 `Widget::start` 与 `Widget::event` 返回 `WidgetResult`。`WidgetError` 会原样穿过 WIT `widget-error` 交给宿主,宿主将其分类为 guest 业务拒绝;SDK 不得吞掉错误,也不得把它伪装成 trap、 fuel、超时或内存错误。 +插件项目必须优先使用 `floatile_sdk::prelude::*`;crate 根的生成 WIT 模块主要供 adapter 与一致性测试 +使用,不作为普通作者需要理解的入口。prelude 的移除或不兼容改名按 SDK major 变更处理。 ## 3. 项目模板 diff --git a/plugins/ai-balance-wasm/src/lib.rs b/plugins/ai-balance-wasm/src/lib.rs index aface56..e5675ed 100644 --- a/plugins/ai-balance-wasm/src/lib.rs +++ b/plugins/ai-balance-wasm/src/lib.rs @@ -1,11 +1,9 @@ //! PP-M5 reference widget: a secret-free AI balance monitor using only generic host capabilities. +use floatile_sdk::host_http; #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, OperationCapability, OperationTerminal, State, Widget, WidgetEvent, - WidgetResult, host_http, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; const CONNECTION_ID: u64 = 1; diff --git a/plugins/clock-wasm/src/lib.rs b/plugins/clock-wasm/src/lib.rs index 39af484..618e4b5 100644 --- a/plugins/clock-wasm/src/lib.rs +++ b/plugins/clock-wasm/src/lib.rs @@ -8,9 +8,7 @@ #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, LogLevel, State, Widget, WidgetEvent, WidgetResult, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; // ---- State(derive State 生成 schema + initial)---- diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index 7649618..a16c5b9 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -19,10 +19,7 @@ #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, LogLevel, OperationCapability, OperationTerminal, State, Widget, - WidgetError, WidgetEvent, WidgetResult, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; /// State 只携带攻击模式;宿主测试用 `initial_state`(已验证 schema)选择行为。 From 027a5ed24968714ed41f45ad02275173610a57ff Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:47:06 +0800 Subject: [PATCH 3/6] test(sdk): add shared lifecycle conformance vectors Establish a language-neutral, versioned JSON suite for every WIT widget-error variant. Validate the suite against the Rust guest bindings, execute it through a real WASM Component in the host runtime, and retain the host-survival assertion so future language adapters must match the same semantics instead of copying Rust expectations. Refs: PP-M7, FR-PLUGIN-01, F11, F12, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-sdk --test conformance --locked; RUSTC_WRAPPER= cargo test -p floatile-runtime --test security sdk_lifecycle_errors_reach_the_host_as_rejections --locked; RUSTC_WRAPPER= cargo check -p floatile-sdk --target wasm32-wasip2 --locked Unverified: TypeScript adapter consumption remains blocked by ADR-0003; lifecycle vectors have not been executed on Windows or macOS hosts --- Cargo.lock | 1 + conformance/sdk-lifecycle-v1.json | 27 +++++++ crates/floatile-runtime/Cargo.toml | 1 + crates/floatile-runtime/tests/security.rs | 90 +++++++++++++-------- crates/floatile-sdk/tests/conformance.rs | 76 +++++++++++++++++ docs/README.md | 1 + docs/plugin-sdk/conformance-kit.md | 27 +++++++ docs/plugin-sdk/sdk-developer-experience.md | 3 + plugins/evil-wasm/src/lib.rs | 13 ++- 9 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 conformance/sdk-lifecycle-v1.json create mode 100644 crates/floatile-sdk/tests/conformance.rs create mode 100644 docs/plugin-sdk/conformance-kit.md diff --git a/Cargo.lock b/Cargo.lock index e81ee0d..7f4c94c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1710,6 +1710,7 @@ dependencies = [ "floatile-store", "floatile-ui-schema", "parking_lot", + "serde", "serde_json", "thiserror 2.0.20", "tokio", diff --git a/conformance/sdk-lifecycle-v1.json b/conformance/sdk-lifecycle-v1.json new file mode 100644 index 0000000..ead4fb8 --- /dev/null +++ b/conformance/sdk-lifecycle-v1.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "engineApiVersion": "1.2.0", + "vectors": [ + { + "id": "start-rejected", + "callback": "start", + "guestError": "rejected", + "message": "conformance start rejection", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-invalid-input", + "callback": "event", + "guestError": "invalid-input", + "message": "conformance event rejection", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-internal", + "callback": "event", + "guestError": "internal", + "message": null, + "expectedHostOutcome": "rejected" + } + ] +} diff --git a/crates/floatile-runtime/Cargo.toml b/crates/floatile-runtime/Cargo.toml index 7c10429..2b4a43f 100644 --- a/crates/floatile-runtime/Cargo.toml +++ b/crates/floatile-runtime/Cargo.toml @@ -26,3 +26,4 @@ wasmtime-wasi = { workspace = true } [dev-dependencies] floatile-platform = { workspace = true } floatile-store = { workspace = true } +serde = { workspace = true } diff --git a/crates/floatile-runtime/tests/security.rs b/crates/floatile-runtime/tests/security.rs index 82a351e..e28e801 100644 --- a/crates/floatile-runtime/tests/security.rs +++ b/crates/floatile-runtime/tests/security.rs @@ -28,6 +28,31 @@ use floatile_store::{AuditRecord, Store}; use floatile_ui_schema::schema::JsonSchema; use serde_json::{Value, json}; +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleSuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleVector { + id: String, + callback: String, + message: Option, + expected_host_outcome: String, +} + +fn lifecycle_suite() -> LifecycleSuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("lifecycle conformance vectors must parse") +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -180,40 +205,41 @@ async fn denied_capability_persists_audit_and_host_survives() { #[tokio::test(flavor = "multi_thread")] async fn sdk_lifecycle_errors_reach_the_host_as_rejections() { let manager = WidgetManager::new().unwrap(); - let start_error = spawn_evil( - &manager, - 81, - evil_grants_none(81), - json!({"mode": "guest-start-error"}), - ); - let result = start_error.start().await; - assert!( - matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) if message.contains("fixture start rejection")), - "SDK start error should remain a guest rejection, got {result:?}" - ); - - let event_error = spawn_evil( - &manager, - 82, - evil_grants_none(82), - json!({"mode": "guest-event-error"}), - ); - event_error - .start() - .await - .expect("event fixture should start"); - let result = event_error - .handle_event(WidgetEvent::Ui(UiEvent { - name: "trigger".into(), - payload_json: "{}".into(), - })) - .await; - assert!( - matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) if message.contains("fixture event rejection")), - "SDK event error should remain a guest rejection, got {result:?}" + let suite = lifecycle_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION ); + for (index, vector) in suite.vectors.iter().enumerate() { + let instance = 81 + u64::try_from(index).expect("bounded vector index"); + let handle = spawn_evil( + &manager, + instance, + evil_grants_none(instance), + json!({"mode": format!("conformance-{}", vector.id)}), + ); + let result = if vector.callback == "start" { + handle.start().await + } else { + handle.start().await.expect("event vector should start"); + handle + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await + }; + assert_eq!(vector.expected_host_outcome, "rejected"); + assert!( + matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) + if vector.message.as_ref().is_none_or(|expected| message.contains(expected))), + "conformance vector {} should remain a guest rejection, got {result:?}", + vector.id + ); + } - let survivor = spawn_evil(&manager, 83, evil_grants_none(83), json!({"mode": "deny"})); + let survivor = spawn_evil(&manager, 89, evil_grants_none(89), json!({"mode": "deny"})); survivor .start() .await diff --git a/crates/floatile-sdk/tests/conformance.rs b/crates/floatile-sdk/tests/conformance.rs new file mode 100644 index 0000000..a762cc7 --- /dev/null +++ b/crates/floatile-sdk/tests/conformance.rs @@ -0,0 +1,76 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::BTreeSet; + +use floatile_sdk::{ENGINE_API_VERSION, WidgetError}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Suite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Vector { + id: String, + callback: String, + guest_error: String, + message: Option, + expected_host_outcome: String, +} + +fn suite() -> Suite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("SDK lifecycle conformance vectors must be valid JSON") +} + +fn widget_error(vector: &Vector) -> WidgetError { + match vector.guest_error.as_str() { + "invalid-input" => WidgetError::InvalidInput( + vector + .message + .clone() + .expect("invalid-input vector requires a message"), + ), + "rejected" => WidgetError::Rejected( + vector + .message + .clone() + .expect("rejected vector requires a message"), + ), + "internal" => WidgetError::Internal, + other => panic!("unknown guest error in conformance vector: {other}"), + } +} + +#[test] +fn lifecycle_vectors_match_the_generated_wit_contract() { + let suite = suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!(suite.engine_api_version, ENGINE_API_VERSION); + + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + assert!(ids.insert(&vector.id), "duplicate vector id: {}", vector.id); + assert!(matches!(vector.callback.as_str(), "start" | "event")); + assert_eq!(vector.expected_host_outcome, "rejected"); + let _ = widget_error(vector); + } + + assert_eq!( + suite + .vectors + .iter() + .map(|vector| vector.guest_error.as_str()) + .collect::>(), + BTreeSet::from(["internal", "invalid-input", "rejected"]), + "vectors must cover every WIT widget-error variant" + ); +} diff --git a/docs/README.md b/docs/README.md index 0504572..263e37e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | | 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | +| SDK 一致性向量 | `plugin-sdk/conformance-kit.md`、`../conformance/` | SDK lifecycle、错误、行为或跨语言 conformance 格式变化 | | 插件契约 | `plugin-sdk/manifest-v1.md`、`plugin-sdk/wit-api-v1.md`、`plugin-sdk/ui-ir-v1.md` | manifest/WIT/UI IR/包格式变化 | | 平台事实 | `platform-matrix/platform-matrix.md` | 获得新的实测证据 | | 工程规则 | `development/` | 本地流程、CI、代码或测试规范变化 | diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md new file mode 100644 index 0000000..8f2b9b6 --- /dev/null +++ b/docs/plugin-sdk/conformance-kit.md @@ -0,0 +1,27 @@ +# SDK Conformance Kit + +> 状态:Implemented(Rust SDK 与 host runtime 自动化验证;TypeScript 尚未实现) +> 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 + +`conformance/` 保存语言无关、版本化的 JSON 向量。Rust SDK、未来 TypeScript SDK 与宿主 runtime +必须消费同一文件;语言专用测试只能负责把向量映射到本语言 API,不得复制或改写预期语义。 + +## 生命周期向量 + +`sdk-lifecycle-v1.json` 固定以下字段: + +- `schemaVersion`:向量文件格式;不识别的版本必须拒绝; +- `engineApiVersion`:必须与根 WIT、host bindings 和 guest SDK 一致; +- `id`:跨语言稳定用例名,不得重复; +- `callback`:`start` 或 `event`; +- `guestError`:WIT `widget-error` 的 kebab-case variant; +- `message`:该 variant 携带的稳定测试 payload,`internal` 为 `null`; +- `expectedHostOutcome`:宿主错误分类,不是语言专用异常名。 + +当前向量覆盖 `invalid-input`、`rejected` 与 `internal` 全部 WIT guest error。runtime 使用真实 +WASM Component 执行同一批向量,必须把它们分类为 guest `Rejected`,并证明随后仍可启动和停止同行 +实例。trap、fuel、epoch timeout、内存、Broker deny、Operation 和 State Patch 的既有安全测试将在后续 +PP-M7 切片逐步登记为同目录的版本化向量。 + +新增语言 SDK 时,第一步必须解析这些文件并拒绝未知 `schemaVersion`、callback、error 或 outcome; +不能用“等价的本地测试”代替共享向量。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index fe7cd61..688654d 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -291,6 +291,9 @@ TypeScript 提供同名语义。测试默认不启动窗口、不访问真实 SQ 测试由 `floatile preview` 在固定 renderer、字体、DPI 和 theme 下输出 screenshot + 可访问 UI tree; 平台窗口行为仍需真实平台验收,不能用快照替代。 +跨语言 lifecycle 与错误分类使用仓库根 `conformance/` 的版本化 JSON 向量,格式与覆盖状态见 +[`conformance-kit.md`](conformance-kit.md)。Rust/TypeScript 测试不得分别维护同名用例的预期结果。 + 实现状态(P0):Rust 侧 `WidgetHarness` 已在 `floatile-runtime::harness` 落地—— `grant/start/emit_ui/wait_for_state(谓词断言)/advance_time/audit/assert_audit`,所有宿主能力仍走生产 deny-by-default Broker;`floatile test` 用它对已构建 `.floatile` 跑无头生命周期冒烟(build→提取→ diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index a16c5b9..f3cfb3f 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -77,8 +77,8 @@ impl Widget for Evil { } fn start(&mut self, ctx: &mut Context) -> WidgetResult { - if self.mode == "guest-start-error" { - return Err(WidgetError::Rejected("fixture start rejection".into())); + if self.mode == "conformance-start-rejected" { + return Err(WidgetError::Rejected("conformance start rejection".into())); } match self.mode.as_str() { // loop 不进 start:让实例先成功启动,测试再以事件触发无限循环,fuel 才 trap。 @@ -96,8 +96,13 @@ impl Widget for Evil { } fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult { - if self.mode == "guest-event-error" { - return Err(WidgetError::InvalidInput("fixture event rejection".into())); + if self.mode == "conformance-event-invalid-input" { + return Err(WidgetError::InvalidInput( + "conformance event rejection".into(), + )); + } + if self.mode == "conformance-event-internal" { + return Err(WidgetError::Internal); } match event { EvilEvent::Trigger => match self.mode.as_str() { From c04f680c1d9fa6f355fc9ca290d5dded5dd92f76 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:48:59 +0800 Subject: [PATCH 4/6] feat(cli): expose the SDK conformance kit Add a stable author command that validates and emits the embedded language-neutral lifecycle suite for adapters, CI, and agents. Preserve the existing automation flags, schema v1 output, exit-code rules, and bounded public diagnostics so consumers do not depend on repository paths or Rust-only tests. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test conformance_cmd --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --lib conformance::tests --locked Unverified: consumption by a production TypeScript adapter remains blocked by ADR-0003 --- crates/floatile-cli/src/conformance.rs | 126 +++++++++++++++++++ crates/floatile-cli/src/lib.rs | 2 + crates/floatile-cli/src/main.rs | 39 +++++- crates/floatile-cli/tests/conformance_cmd.rs | 37 ++++++ docs/plugin-sdk/conformance-kit.md | 4 + docs/plugin-sdk/sdk-developer-experience.md | 1 + 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 crates/floatile-cli/src/conformance.rs create mode 100644 crates/floatile-cli/tests/conformance_cmd.rs diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs new file mode 100644 index 0000000..6d1f5b8 --- /dev/null +++ b/crates/floatile-cli/src/conformance.rs @@ -0,0 +1,126 @@ +//! Versioned SDK conformance kit exposed to language adapters and CI. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::OUTPUT_SCHEMA_VERSION; + +pub const LIFECYCLE_SUITE_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" +)); + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleSuite { + pub schema_version: u32, + pub engine_api_version: String, + pub vectors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleVector { + pub id: String, + pub callback: String, + pub guest_error: String, + pub message: Option, + pub expected_host_outcome: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConformanceReport { + pub schema_version: u32, + pub status: &'static str, + pub suite: &'static str, + pub contract: LifecycleSuite, + pub warnings: Vec, +} + +#[derive(Debug, Error)] +pub enum ConformanceError { + #[error("内置 conformance JSON 无效")] + InvalidJson, + #[error("不支持的 conformance schemaVersion: {0}")] + UnsupportedSchema(u32), + #[error("conformance 向量无效: {0}")] + InvalidVector(String), +} + +impl ConformanceError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidJson => "FCONF_JSON", + Self::UnsupportedSchema(_) => "FCONF_SCHEMA_VERSION", + Self::InvalidVector(_) => "FCONF_VECTOR", + } + } +} + +pub fn lifecycle_report() -> Result { + let contract: LifecycleSuite = + serde_json::from_str(LIFECYCLE_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; + validate(&contract)?; + Ok(ConformanceReport { + schema_version: OUTPUT_SCHEMA_VERSION, + status: "ok", + suite: "sdk-lifecycle-v1", + contract, + warnings: Vec::new(), + }) +} + +fn validate(suite: &LifecycleSuite) -> Result<(), ConformanceError> { + if suite.schema_version != 1 { + return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); + } + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + if !ids.insert(vector.id.as_str()) { + return Err(ConformanceError::InvalidVector("vector id 重复".to_owned())); + } + if !matches!(vector.callback.as_str(), "start" | "event") { + return Err(ConformanceError::InvalidVector( + "callback 不受支持".to_owned(), + )); + } + if !matches!( + vector.guest_error.as_str(), + "invalid-input" | "rejected" | "internal" + ) { + return Err(ConformanceError::InvalidVector( + "guestError 不受支持".to_owned(), + )); + } + if vector.expected_host_outcome != "rejected" { + return Err(ConformanceError::InvalidVector( + "expectedHostOutcome 不受支持".to_owned(), + )); + } + let requires_message = vector.guest_error != "internal"; + if requires_message != vector.message.is_some() { + return Err(ConformanceError::InvalidVector( + "message 与 guestError 不匹配".to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_lifecycle_suite_is_valid_and_versioned() { + let report = lifecycle_report().expect("embedded suite should validate"); + assert_eq!(report.schema_version, OUTPUT_SCHEMA_VERSION); + assert_eq!(report.suite, "sdk-lifecycle-v1"); + assert_eq!(report.contract.schema_version, 1); + assert_eq!(report.contract.engine_api_version, "1.2.0"); + assert_eq!(report.contract.vectors.len(), 3); + } +} diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index def48f5..b8ff529 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -5,6 +5,7 @@ pub mod build; pub mod check; +pub mod conformance; pub mod dev; pub mod inspect; pub mod install; @@ -18,6 +19,7 @@ pub mod test; pub use build::{BuildError, build_project, package}; pub use check::{CheckError, CheckPhases, CheckReport, CheckWarning, check_project}; +pub use conformance::{ConformanceError, ConformanceReport, lifecycle_report}; pub use dev::{BuildStatus, build_once, dev_loop, ensure_project}; pub use inspect::{InspectError, InspectReport, inspect_package, inspect_package_bytes}; pub use install::{InstallError, InstalledPackage, install_dir, install_package}; diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index db56b9e..e755e77 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -5,8 +5,8 @@ use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ - CommandErrorReport, build, check, dev, inspect, install, instance, package, preview, project, - run, test, + CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, + preview, project, run, test, }; use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId}; @@ -14,7 +14,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -31,6 +31,7 @@ fn main() -> ExitCode { "preview" => cmd_preview(&args[2..]), "run" => cmd_run(&args[2..]), "schema" => cmd_schema(&args[2..]), + "conformance" => cmd_conformance(&args[2..]), other => { eprintln!("未知命令: {other}"); ExitCode::from(2) @@ -38,6 +39,38 @@ fn main() -> ExitCode { } } +fn cmd_conformance(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + if let Err(detail) = author_positionals(args, &[], &[], 0) { + return render_basic_error("FCONF_ARGUMENT", &detail, json, true); + } + match conformance::lifecycle_report() { + Ok(report) => { + if json { + println!("{}", serialize_json(&report)); + } else { + println!( + "conformance: PASS suite={} engine={} vectors={}", + report.suite, + report.contract.engine_api_version, + report.contract.vectors.len() + ); + for vector in report.contract.vectors { + println!( + " {} callback={} guest-error={} host={}", + vector.id, + vector.callback, + vector.guest_error, + vector.expected_host_outcome + ); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_check(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let deny_warnings = args.iter().any(|argument| argument == "--deny-warnings"); diff --git a/crates/floatile-cli/tests/conformance_cmd.rs b/crates/floatile-cli/tests/conformance_cmd.rs new file mode 100644 index 0000000..5dfcd4d --- /dev/null +++ b/crates/floatile-cli/tests/conformance_cmd.rs @@ -0,0 +1,37 @@ +#![allow(clippy::unwrap_used)] + +use std::process::Command; + +#[test] +fn conformance_command_exposes_the_versioned_lifecycle_suite() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "conformance", + "--json", + "--no-interactive", + "--deny-warnings", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schemaVersion"], 1); + assert_eq!(report["status"], "ok"); + assert_eq!(report["suite"], "sdk-lifecycle-v1"); + assert_eq!(report["contract"]["engineApiVersion"], "1.2.0"); + assert_eq!(report["contract"]["vectors"].as_array().unwrap().len(), 3); + assert_eq!(report["warnings"], serde_json::json!([])); +} + +#[test] +fn conformance_command_rejects_unknown_arguments() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["conformance", "unexpected", "--json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(report["code"], "FCONF_ARGUMENT"); +} diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md index 8f2b9b6..235eef6 100644 --- a/docs/plugin-sdk/conformance-kit.md +++ b/docs/plugin-sdk/conformance-kit.md @@ -25,3 +25,7 @@ PP-M7 切片逐步登记为同目录的版本化向量。 新增语言 SDK 时,第一步必须解析这些文件并拒绝未知 `schemaVersion`、callback、error 或 outcome; 不能用“等价的本地测试”代替共享向量。 + +`floatile conformance --json --no-interactive` 会校验 CLI 内嵌的当前 suite 并输出稳定的 schema v1 +报告;language adapter、CI 与 Agent 应消费该命令,而不是依赖仓库路径。`--deny-warnings` 与其他作者 +命令语义一致;当前 suite 没有 warning。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 688654d..ff0cc07 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -211,6 +211,7 @@ Detected capabilities: | `floatile build` | 可复现地产生 `.floatile`,默认不签名 | | `floatile inspect` | 显示 manifest、版本轴、权限、预算、entry digest | | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | +| `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | | `floatile instance create/list/get/configure/start/stop/delete` | 按精确安装版本管理持久实例与 desired state | 所有命令必须支持: From 97cf91e0acb11e71575539fc9df79851eb53f656 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:50:00 +0800 Subject: [PATCH 5/6] docs(sdk): record the PP-M7 contract baseline Mark PP-M7 as in progress and record the implemented Rust lifecycle, author prelude, shared conformance vectors, and CLI surface. Keep generated documentation, migration tooling, broader adversarial vectors, TypeScript, and the milestone exit gate explicitly incomplete. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01, ADR-0003 Tests: cargo fmt --all -- --check; git diff --check Unverified: TypeScript runtime and SDK, generated API documentation, migrate command, full cross-language adversarial vectors, Windows and macOS conformance execution --- docs/product/plugin-platform-roadmap.md | 7 ++++++- docs/product/requirements.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index 2711105..fadabe1 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -173,7 +173,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M4 | Rust 作者闭环 | 已完成(自动化契约与 Xvfb 验证;公开发布受许可门阻断) | 可发布方式待许可决定的 SDK 解析、生成模板修复、dev/test/preview/build/install/run/inspect | 干净目录中的示例插件无需仓库私有路径即可完成全流程;JSON 契约有测试 | SDK、CLI、runtime、shell、docs | | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | -| PP-M7 | SDK 与语言生态 | 规划中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | +| PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | | PP-M8 | 分发与信任 | 规划中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -218,6 +218,11 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 `widget.ftui`,证明参考插件覆盖上述监控 UI 契约且包内不含第三方 `.slint`/HTML;schema、renderer、 SDK 与 shell 的契约/真实 Slint 无头编译测试均通过。真实屏幕阅读器行为、跨平台视觉与完整无障碍仍属 PP-M10 环境验收,不把它们误记为 PP-M6 自动化契约证据; +- PP-M7 已启动 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT + `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle JSON 覆盖全部 + guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` + 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 + TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; - TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 514e6c3..45c29e6 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -62,7 +62,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | 布局/存储 | 部分验证 | 核心层已有强类型 monitor/DPI/物理坐标模型和平台无关恢复算法;SQLite v2 migration 已持久化物理尺寸、scale factor 与 `lost_monitor`;shell 已接入启动保存/恢复(位置/尺寸/模式)、拖动/缩放/模式/热键/退出保存与显示器变化重恢复,Xvfb+Openbox 与 macOS 单屏重启恢复已实测;真实多屏/DPI/热插拔与 Windows 实机验证待做 | | 插件实例内核(PP-M1) | 已实现,部分验证 | SQLite v4 实例模型和 `floatile instance create/list/get/configure/start/stop/delete` 已打通;创建/配置与 shell 恢复共用安装目录完整性和 Config JSON Schema 复验。shell 控制面提供安装/实例列表、Schema 驱动配置、desired 启停/删除、observed `starting/running/failed/stopped` 与手动 retry;SQLite、安装文件和配置解析均在有界后台 worker。runtime 只有在 WASM `start()` 成功后才报告 running,晚期退出按稳定 code 隔离。Linux X11/Xvfb 自动证据已验证同包双窗口、单实例安装缺失失败隔离和恢复后手动 retry;Windows、macOS、Wayland 控制面交互与真实桌面多窗口仍未验证。 | | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | -| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | +| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | | 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core::install` 提供 InstallMeta 与内容 digest 单源,`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名仍待做 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | From 978461ab1b397eda3c9fe66b2bedb86a871b00ac Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 08:50:51 +0800 Subject: [PATCH 6/6] test(cli): keep conformance validation fallible Return the embedded-suite validation error from the unit test instead of using an expect panic, preserving the workspace rule that recoverable Result values remain explicit under the all-target clippy gate. Refs: PP-M7, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo clippy -p floatile-cli --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo test -p floatile-cli --lib conformance::tests --locked Unverified: none --- crates/floatile-cli/src/conformance.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs index 6d1f5b8..a1d266e 100644 --- a/crates/floatile-cli/src/conformance.rs +++ b/crates/floatile-cli/src/conformance.rs @@ -115,12 +115,13 @@ mod tests { use super::*; #[test] - fn embedded_lifecycle_suite_is_valid_and_versioned() { - let report = lifecycle_report().expect("embedded suite should validate"); + fn embedded_lifecycle_suite_is_valid_and_versioned() -> Result<(), ConformanceError> { + let report = lifecycle_report()?; assert_eq!(report.schema_version, OUTPUT_SCHEMA_VERSION); assert_eq!(report.suite, "sdk-lifecycle-v1"); assert_eq!(report.contract.schema_version, 1); assert_eq!(report.contract.engine_api_version, "1.2.0"); assert_eq!(report.contract.vectors.len(), 3); + Ok(()) } }