diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 3222f81d..e52e8f03 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -107,6 +107,8 @@ jobs: shared-key: native-host-api - name: Build the native acceptance host and pinned Hub CLI run: | + # Fresh child processes catch native response keys missing from the string lookup table. + cargo test --manifest-path .sources/makepad/Cargo.toml -p makepad-widgets splash_host::tests cargo build --locked --release -p octosense-shell --example host-api-lab --features acceptance-fixtures cargo build --locked -p octosense-app-hub --bin hub - name: Signed app tool calls a real OS permission API diff --git a/.github/workflows/phone.yml b/.github/workflows/phone.yml index 07e74ba7..8811d33f 100644 --- a/.github/workflows/phone.yml +++ b/.github/workflows/phone.yml @@ -120,6 +120,10 @@ jobs: # The linked Terminal (the desktop's default): its AI tools reach # the live emulator behind the module's TermTabs root. cargo test --locked --features mobile-apps,app-terminal -p octosense-shell terminal + # The opt-in Wasm service must keep fresh per-call guest state and + # enforce resource, cancellation and current-admission boundaries. + cargo test --locked -p octosense-wasm-host + cargo test --locked --features mobile-apps,wasm-lab -p octosense-shell wasm_service::tests cargo test --locked -p makepad-widgets splash_policy cargo test --locked -p makepad-script-std gate::tests # The two-lane scenario (crates/shell/src/host_tools/scenario_tests.rs): diff --git a/Cargo.lock b/Cargo.lock index eeea0f35..d2f828ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8199,6 +8199,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.24.0", "uuid", + "wat", ] [[package]] @@ -8234,6 +8235,19 @@ dependencies = [ "wat", ] +[[package]] +name = "octosense-wasm-phone-smoke" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-app-hub", + "octosense-app-policy", + "octosense-appstore", + "octosense-shell", + "serde_json", +] + [[package]] name = "ohos-sys" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 51d51847..1b582397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ [workspace] resolver = "2" members = [ + "crates/wasm-phone-smoke", "desktop", "phone", "crates/kernel", diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 8284f602..2e48296e 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -170,6 +170,7 @@ wasm-lab = ["app-hub", "dep:octosense-wasm-host"] toolbox-peers = ["octosense-ai-host/toolbox-peers"] [dev-dependencies] +wat = "1" # The two-lane scenario's external client (src/host_tools/scenario_tests.rs): # a Talk to Octos connection over the kernel's WebSocket, as a browser makes. tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/crates/shell/examples/connected_support/mod.rs b/crates/shell/examples/connected_support/mod.rs index 061ad1ca..9659099a 100644 --- a/crates/shell/examples/connected_support/mod.rs +++ b/crates/shell/examples/connected_support/mod.rs @@ -17,6 +17,8 @@ const IDS: &[&str] = &[ #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.apilab", #[cfg(feature = "acceptance-fixtures")] + "org.octosense.samples.wasmprobe", + #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backend", #[cfg(feature = "acceptance-fixtures")] "org.octosense.samples.backendother", diff --git a/crates/shell/src/wasm_service.rs b/crates/shell/src/wasm_service.rs index 3b2952b9..bb277d11 100644 --- a/crates/shell/src/wasm_service.rs +++ b/crates/shell/src/wasm_service.rs @@ -18,78 +18,332 @@ //! gate and the tool executor both require the app's `wasm` capability, and //! the service checks the admitted manifest again before it loads anything. //! -//! **Where it runs.** Requests arrive on the UI thread. Each app gets one -//! worker thread holding its instances, so a slow function holds up only -//! its own app. Compiled code is cached in the host directory -//! (`wasm-cache`), keyed by the module's digest. An instance that trapped or -//! ran past its deadline is replaced before the next call. -//! -//! Not done yet: an app updated while the shell runs keeps its old -//! functions until the shell restarts, and a worker lives as long as the -//! shell. +//! **Where it runs.** A bounded worker per active app caches compiled Programs, +//! never guest instances. Every invocation starts with fresh memory/globals/tables. +//! Admission, cancellation and deadline are checked before execution and delivery; +//! an update or withdrawal invalidates loaded Programs. Workers exit when idle. +//! The host-only disk cache holds compiled code keyed by module digest, not data. use std::collections::{BTreeMap, HashMap}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{channel, Receiver, SendError, Sender}; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use octosense_appstore::services::{HostService, Replier, ServiceCall, ServiceHost}; -use octosense_wasm_host::{Instance, Limits, Program, Runtime}; +use octosense_wasm_host::{Limits, Program, Runtime}; use serde_json::{json, Value}; use crate::host_tools::script_apps; -/// The most modules one app's bundle may carry. const MAX_MODULES: usize = 8; +const MAX_WORKERS: usize = 4; +const MAX_QUEUED_PER_APP: usize = 4; +const MAX_INPUT_BYTES: usize = 1 << 20; +const MAX_BUFFERED_BYTES: usize = 16 << 20; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const WORKER_IDLE: Duration = Duration::from_secs(5); -/// One engine, one epoch ticker and one code cache for the process. static RUNTIME: OnceLock> = OnceLock::new(); +static WORKERS: Mutex>> = Mutex::new(None); +static BUFFERED_BYTES: AtomicUsize = AtomicUsize::new(0); + +struct Worker { + jobs: SyncSender, + retired: Arc, + admission: Arc, +} + +/// Enqueue only clones this host-owned identity. Bundle IO, signature checks +/// and digesting run on the worker, including the first cold admission. +#[derive(Default)] +struct AdmissionEpoch { + admitted: OnceLock>, + obsolete: AtomicBool, +} + +/// Removed only after the worker has dropped its queue and compiled modules. +/// A closing worker keeps its slot until then, even during revision churn. +struct RetireWorker { + app: String, + retired: Arc, +} +impl Drop for RetireWorker { + fn drop(&mut self) { + let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(workers) = workers.as_mut() { + if workers + .get(&self.app) + .is_some_and(|worker| Arc::ptr_eq(&worker.retired, &self.retired)) + { + workers.remove(&self.app); + } + } + } +} + +/// Includes executing input: cancelling a queued request cannot free its +/// reservation until the worker actually drops those bytes. +struct Reservation(usize); +impl Reservation { + fn acquire(bytes: usize) -> Result { + BUFFERED_BYTES + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|total| *total <= MAX_BUFFERED_BYTES) + }) + .map(|_| Self(bytes)) + .map_err(|_| "wasm input queue is full; try again later".into()) + } +} +impl Drop for Reservation { + fn drop(&mut self) { + BUFFERED_BYTES.fetch_sub(self.0, Ordering::AcqRel); + } +} -/// Each app's worker, by app id. -static WORKERS: Mutex>>> = Mutex::new(None); +/// Authority is the signed, digest-checked bundle, never request arguments. +/// Comparing the whole admitted manifest includes the version and grants. +#[derive(Clone, Debug, PartialEq)] +struct Admission { + root: PathBuf, + bundle: PathBuf, + manifest: Value, +} +impl Admission { + fn current(app: &str) -> Result { + let (root, bundle) = script_apps::admitted_bundle(app)?; + let loaded = script_apps::from_bundle(&bundle)?; + if !loaded.families.contains("wasm") { + return Err(format!("{app} was not granted the wasm service")); + } + Ok(Self { + root, + bundle, + manifest: loaded.manifest, + }) + } + fn verify(&self, app: &str) -> Result<(), String> { + if Self::current(app)? != *self { + return Err("wasm app admission changed; retry from the current app".into()); + } + Ok(()) + } +} struct Job { method: String, - args: Value, + input: Vec, host_dir: PathBuf, + admission: Arc, + deadline: Instant, reply: Replier, + _reservation: Reservation, +} +impl Job { + fn live(&self) -> bool { + self.reply.is_pending() && Instant::now() < self.deadline + } + fn check(&self, app: &str) -> Result<&Admission, String> { + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + if self.admission.obsolete.load(Ordering::Acquire) { + return Err("wasm app admission changed; retry from the current app".into()); + } + let admitted = self + .admission + .admitted + .get_or_init(|| Admission::current(app)); + let verified = admitted + .as_ref() + .map_err(Clone::clone) + .and_then(|admission| { + admission.verify(app)?; + Ok(admission) + }); + let admission = match verified { + Ok(admission) => admission, + Err(error) => { + self.admission.obsolete.store(true, Ordering::Release); + return Err(error); + } + }; + // The cache is host-owned, not a caller-selected or app directory. + if self.host_dir != admission.root.join(".host") { + return Err("wasm host directory does not belong to this app store".into()); + } + if !self.live() { + return Err("wasm invocation cancelled or past its deadline".into()); + } + Ok(admission) + } } -/// The `wasm` family for the Card runner and the tool executor. -pub struct WasmService; +/// Serialize into bounded storage, rather than allocating an unlimited JSON +/// buffer and only then discovering the request is too large. +fn input_bytes(args: &Value) -> Result, String> { + struct Bounded(Vec); + impl Write for Bounded { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > MAX_INPUT_BYTES.saturating_sub(self.0.len()) { + return Err(std::io::Error::other("wasm input exceeds 1 MiB")); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + if let Value::String(text) = args { + if text.len() > MAX_INPUT_BYTES { + return Err("wasm input exceeds 1 MiB".into()); + } + return Ok(text.as_bytes().to_vec()); + } + let mut out = Bounded(Vec::new()); + serde_json::to_writer(&mut out, args).map_err(|error| error.to_string())?; + Ok(out.0) +} +pub struct WasmService; impl HostService for WasmService { fn family(&self) -> &'static str { "wasm" } - + fn timeout(&self, _call: &ServiceCall) -> Duration { + REQUEST_TIMEOUT + } fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { if call.from_sheet { reply.send(Err("the wasm service has no sheet".into())); return; } + if !reply.is_pending() { + return; + } + let deadline = Instant::now() + REQUEST_TIMEOUT; + let prepared = (|| { + let input = input_bytes(&call.args)?; + let reservation = Reservation::acquire(input.len())?; + Ok((input, reservation)) + })(); + let (input, reservation) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + reply.send(Err(error)); + return; + } + }; let app = call.app_id.clone(); - let job = Job { method: call.method().to_string(), host_dir: call.host_dir, args: call.args, reply }; - let mut workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); - let workers = workers.get_or_insert_with(HashMap::new); - // A worker that ended (only a panic ends one) is replaced. - let job = match workers.get(&app) { - Some(jobs) => match jobs.send(job) { + let mut job = Job { + method: call.method().to_string(), + host_dir: call.host_dir, + input, + admission: Arc::default(), + deadline, + reply, + _reservation: reservation, + }; + if !job.live() { + return; + } + let mut registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + let workers = registry.get_or_insert_with(HashMap::new); + if let Some(worker) = workers.get_mut(&app) { + if worker.retired.load(Ordering::Acquire) { + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } + if worker.admission.obsolete.load(Ordering::Acquire) { + worker.admission = Arc::default(); + } + job.admission = worker.admission.clone(); + match worker.jobs.try_send(job) { Ok(()) => return, - Err(SendError(job)) => job, + Err(TrySendError::Full(job)) => { + job.reply + .send(Err("wasm app queue is full; try again later".into())); + return; + } + Err(TrySendError::Disconnected(job)) => { + // The worker's guard removes its slot after all resources + // have dropped. Do not create an overlapping replacement. + job.reply + .send(Err("wasm worker is closing; try again later".into())); + return; + } + }; + } + if workers.len() >= MAX_WORKERS { + job.reply + .send(Err("wasm workers are busy; try again later".into())); + return; + } + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + let retired = Arc::new(AtomicBool::new(false)); + let admission = job.admission.clone(); + // Reserve the worker slot and queue before spawning, then release the + // registry mutex: neither thread creation nor admission holds it. + if jobs.try_send(job).is_err() { + unreachable!("new wasm queue has room"); + } + workers.insert( + app.clone(), + Worker { + jobs, + retired: retired.clone(), + admission, }, - None => job, - }; - let (jobs, queue) = channel(); + ); + // Keep ownership outside the spawn closure too, so a spawn failure + // can explicitly answer every request accepted during this interval. + let queue = Arc::new(Mutex::new(Some(queue))); + drop(registry); + self.start_worker(app, retired, queue); + } +} + +impl WasmService { + fn start_worker( + &self, + app: String, + retired: Arc, + queue: Arc>>>, + ) { let name = app.clone(); - let spawned = std::thread::Builder::new().name(format!("wasm {app}")).spawn(move || work(name, queue)); + let worker_queue = queue.clone(); + let worker_retired = retired.clone(); + let spawned = std::thread::Builder::new() + .name(format!("wasm {app}")) + .spawn(move || { + let _retire = RetireWorker { + app: name.clone(), + retired: worker_retired.clone(), + }; + let queue = worker_queue + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .unwrap(); + work(name, queue, &worker_retired); + }); if let Err(error) = spawned { - job.reply.send(Err(format!("cannot start {app}'s functions: {error}"))); - return; + let registry = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + retired.store(true, Ordering::Release); + drop(registry); + if let Some(queue) = queue.lock().unwrap_or_else(|e| e.into_inner()).take() { + for job in queue.try_iter() { + job.reply + .send(Err(format!("cannot start {app}'s functions: {error}"))); + } + } + drop(RetireWorker { app, retired }); } - let _ = jobs.send(job); - workers.insert(app, jobs); } } @@ -97,25 +351,79 @@ pub fn register() { octosense_appstore::services::register_host_service(Box::new(WasmService)); } -/// One app's worker: loads its modules on the first request (again on the -/// next one, if that failed), then answers each request in turn. -fn work(app: String, jobs: Receiver) { +fn work(app: String, jobs: Receiver, retired: &Arc) { let mut lab: Option = None; - for job in jobs { - if lab.is_none() { - match Lab::load(&app, &job.host_dir) { - Ok(loaded) => lab = Some(loaded), - Err(error) => { - makepad_widgets::log!("wasm {app}: {error}"); - job.reply.send(Err(error)); - continue; + loop { + let job = match jobs.recv_timeout(WORKER_IDLE) { + Ok(job) => job, + Err(_) => { + // Serialize idle retirement with enqueue. A sender that won + // the timeout race still gets its accepted request processed. + let _workers = WORKERS.lock().unwrap_or_else(|e| e.into_inner()); + if let Ok(job) = jobs.try_recv() { + job + } else { + retired.store(true, Ordering::Release); + // The outer retirement guard removes the slot only once + // this function has dropped the Lab and queued inputs. + return; } } + }; + let result = process(&app, &mut lab, &job); + // Cancellation can win while admission is being checked. Replier's + // pending-request check remains the final isolate boundary. + if job.live() { + job.reply.send(result); } - if let Some(lab) = lab.as_mut() { - job.reply.send(lab.answer(&job.method, &job.args)); + } +} + +fn process(app: &str, lab: &mut Option, job: &Job) -> Result { + process_checked(app, lab, job, || {}) +} + +// The completion boundary is explicit so regressions can replace the real +// admitted bundle precisely after guest execution, without timing sleeps. +fn process_checked( + app: &str, + lab: &mut Option, + job: &Job, + before_delivery: impl FnOnce(), +) -> Result { + let admission = match job.check(app) { + Ok(admission) => admission, + Err(error) => { + *lab = None; + return Err(error); } + }; + if lab + .as_ref() + .is_some_and(|loaded| loaded.admission != *admission) + { + *lab = None; + } + if lab.is_none() { + *lab = Some(Lab::load(app, job)?); + } + // Compilation and start-up work must not revive an expired request. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); + } + let result = + lab.as_mut() + .unwrap() + .answer(&job.method, &job.input, job.deadline, job.reply.clone()); + before_delivery(); + // A withdrawal, changed grant, update or tamper while code runs vetoes + // its result and releases the old compiled-module references. + if let Err(error) = job.check(app) { + *lab = None; + return Err(error); } + result } fn runtime(host_dir: &Path) -> Result<&'static Runtime, String> { @@ -133,6 +441,7 @@ struct Lab { /// Which module exports each function. owner: BTreeMap, stats: BTreeMap, + admission: Admission, } struct Module { @@ -141,9 +450,9 @@ struct Module { load_ms: f64, from_cache: bool, program: Program, - instance: Instance, - /// Fresh instances made after a trap or a deadline. - renewed: u32, + /// Diagnostic high-water mark only: guest memory is never retained. + memory_bytes: usize, + invocations: u64, } /// How one function has run: in the worker, from input to output. @@ -156,12 +465,10 @@ struct Stats { } impl Lab { - fn load(app: &str, host_dir: &Path) -> Result { - let (_, bundle) = script_apps::admitted_bundle(app)?; - if !script_apps::from_bundle(&bundle)?.families.contains("wasm") { - return Err(format!("{app} was not granted the wasm service")); - } - let runtime = runtime(host_dir)?; + fn load(app: &str, job: &Job) -> Result { + let admission = job.check(app)?; + let bundle = &admission.bundle; + let runtime = runtime(&job.host_dir)?; let mut files: Vec = std::fs::read_dir(bundle.join("fns")) .map_err(|_| format!("{app}'s bundle has no fns directory"))? .flatten() @@ -173,34 +480,76 @@ impl Lab { .collect(); files.sort(); if files.is_empty() || files.len() > MAX_MODULES { - return Err(format!("{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules")); + return Err(format!( + "{app}'s bundle must carry 1 to {MAX_MODULES} fns/*.wasm modules" + )); } - let mut lab = Lab { app: app.to_string(), runtime, modules: Vec::new(), owner: BTreeMap::new(), stats: BTreeMap::new() }; + let mut lab = Lab { + app: app.to_string(), + runtime, + modules: Vec::new(), + owner: BTreeMap::new(), + stats: BTreeMap::new(), + admission: admission.clone(), + }; for path in files { - let file = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + job.check(app)?; + let file = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + if std::fs::metadata(&path).map_err(|e| e.to_string())?.len() + > runtime.limits().module_bytes as u64 + { + return Err(format!("{file}: module exceeds the size limit")); + } let bytes = std::fs::read(&path).map_err(|e| format!("{file}: {e}"))?; let started = Instant::now(); let program = runtime.load(&bytes).map_err(|e| format!("{file}: {e}"))?; - let instance = runtime.instantiate(&program).map_err(|e| format!("{file}: {e}"))?; let load_ms = started.elapsed().as_secs_f64() * 1e3; for function in program.functions() { - if lab.owner.insert(function.clone(), lab.modules.len()).is_some() { - return Err(format!("{file}: {function} is exported by another module too")); + if lab + .owner + .insert(function.clone(), lab.modules.len()) + .is_some() + { + return Err(format!( + "{file}: {function} is exported by another module too" + )); } } let from_cache = program.from_cache(); makepad_widgets::log!( "wasm {app}: {file} ({} KiB) {} in {load_ms:.1} ms: {}", bytes.len() / 1024, - if from_cache { "loaded from the cache" } else { "compiled" }, + if from_cache { + "loaded from the cache" + } else { + "compiled" + }, program.functions().join(", ") ); - lab.modules.push(Module { file, bytes: bytes.len(), load_ms, from_cache, program, instance, renewed: 0 }); + lab.modules.push(Module { + file, + bytes: bytes.len(), + load_ms, + from_cache, + program, + memory_bytes: 0, + invocations: 0, + }); } Ok(lab) } - fn answer(&mut self, method: &str, args: &Value) -> Result { + fn answer( + &mut self, + method: &str, + input: &[u8], + deadline: Instant, + reply: Replier, + ) -> Result { if method == "functions" { // Also in the log: where there is no remote instrument (an // Android build), the numbers are read from logcat. @@ -212,20 +561,21 @@ impl Lab { return Err(format!("{} has no function {method:?}", self.app)); }; let module = &mut self.modules[index]; - if module.instance.spent() { - module.instance = self.runtime.instantiate(&module.program).map_err(|e| e.to_string())?; - module.renewed += 1; - } - let input = match args { - Value::String(text) => text.clone().into_bytes(), - other => serde_json::to_vec(other).map_err(|e| e.to_string())?, - }; let started = Instant::now(); - let result = module.instance.call(method, &input); + let mut instance = self + .runtime + .instantiate_guarded(&module.program, deadline, move || reply.is_pending()) + .map_err(|error| error.to_string())?; + module.invocations += 1; + let result = instance.call(method, input); let us = started.elapsed().as_secs_f64() * 1e6; - for line in module.instance.take_logs() { + module.memory_bytes = module.memory_bytes.max(instance.memory_bytes()); + for line in instance.take_logs() { makepad_widgets::log!("wasm {}: {line}", self.app); } + // Drop guest memory, mutable globals, tables and logs after every + // invocation, including successful calls and guest-returned errors. + drop(instance); let stats = self.stats.entry(method.to_string()).or_default(); stats.calls += 1; stats.total_us += us; @@ -234,7 +584,8 @@ impl Lab { stats.errors += 1; error.to_string() })?; - Ok(serde_json::from_slice(&output).unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) + Ok(serde_json::from_slice(&output) + .unwrap_or_else(|_| json!({"text": String::from_utf8_lossy(&output)}))) } fn describe(&self) -> Value { @@ -243,7 +594,7 @@ impl Lab { .iter() .map(|m| { json!({"file": m.file, "bytes": m.bytes, "load_ms": round(m.load_ms), - "from_cache": m.from_cache, "memory_bytes": m.instance.memory_bytes(), "renewed": m.renewed}) + "from_cache": m.from_cache, "memory_bytes": m.memory_bytes, "invocations": m.invocations, "renewed": m.invocations.saturating_sub(1), "instance_policy": "fresh-per-call"}) }) .collect(); let stats: serde_json::Map = self @@ -277,12 +628,22 @@ mod tests { let dir = script_apps::tests::stamped_bundle("wasmlab", "tools", |_, _| {}); let loaded = script_apps::from_bundle(&dir).unwrap(); let _ = std::fs::remove_dir_all(&dir); - let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); - assert_eq!(names, ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"]); + let names: Vec<&str> = loaded + .tools + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + assert_eq!( + names, + ["wasmlab.find_slots", "wasmlab.rank", "wasmlab.diff"] + ); assert_eq!(loaded.host_methods["wasmlab.find_slots"], "wasm.find_slots"); assert_eq!(loaded.host_methods["wasmlab.rank"], "wasm.fuzzy_rank"); assert_eq!(loaded.host_methods["wasmlab.diff"], "wasm.text_diff"); - assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); + assert!(loaded + .tools + .iter() + .all(|t| t["risk"] == "read" && t["output_schema"]["type"] == "object")); assert_eq!(loaded.families.iter().collect::>(), ["wasm"]); assert_eq!(loaded.generic, ["ask_user_question"]); } @@ -298,10 +659,19 @@ mod tests { /// What the app's script would get from `host.request(service, args)`. fn request(app: &str, service: &str, args: Value, host_dir: &Path) -> Result { let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: true, host_dir: host_dir.into() }; + let call = ServiceCall { + app_id: app.into(), + service: service.into(), + args, + from_sheet: false, + may_prompt: true, + host_dir: host_dir.into(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); for _ in 0..1000 { - if let Some((_, _, answer)) = octosense_appstore::services::take_replies_for(&[heap]).pop() { + if let Some((_, _, answer)) = + octosense_appstore::services::take_replies_for(&[heap]).pop() + { return answer.map(|text| serde_json::from_str(&text).unwrap()); } std::thread::sleep(Duration::from_millis(10)); @@ -326,6 +696,438 @@ mod tests { }); } + // A real guest deliberately retains the previous input in both linear + // memory and a mutable global. Reusing the old instance leaks it on recall. + const STATE_GUEST: &str = r#"(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))))"#; + + fn ship_state(id: &'static str, version: &str, granted: bool) { + ship(id, "state", |dir, manifest| { + manifest["version"] = json!(version); + manifest["capabilities"] = if granted { json!(["wasm"]) } else { json!([]) }; + manifest.as_object_mut().unwrap().remove("agent"); + std::fs::remove_file(dir.join("tools.json")).unwrap(); + std::fs::remove_file(dir.join("AGENT.md")).unwrap(); + std::fs::remove_file(dir.join("fns/wasmlab.wasm")).unwrap(); + std::fs::write( + dir.join("fns/state.wasm"), + wat::parse_str(if version == "2.0.0" { + STATE_GUEST.replace( + "(global $size (mut i32) (i32.const 0))", + "(global $size (mut i32) (i32.const 3)) (data (i32.const 65) \"new\")", + ) + } else { + STATE_GUEST.to_string() + }) + .unwrap(), + ) + .unwrap(); + }); + } + + fn captured_job(app: &str, method: &str, host_dir: &Path) -> (usize, Job) { + struct Capture(Arc>>); + impl HostService for Capture { + fn family(&self) -> &'static str { + "wasm_capture" + } + fn call(&mut self, _: ServiceCall, reply: Replier, _: &mut dyn ServiceHost) { + *self.0.lock().unwrap() = Some(reply); + } + } + let captured = Arc::new(Mutex::new(None)); + octosense_appstore::services::register_host_service(Box::new(Capture(captured.clone()))); + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: app.into(), + service: "wasm_capture.capture".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.into(), + }, + heap, + 1, + &mut NoSheet, + ); + let reply = captured.lock().unwrap().take().unwrap(); + ( + heap, + Job { + method: method.into(), + input: Vec::new(), + host_dir: host_dir.into(), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current(app).unwrap())), + obsolete: AtomicBool::new(false), + }), + deadline: Instant::now() + REQUEST_TIMEOUT, + reply, + _reservation: Reservation::acquire(0).unwrap(), + }, + ) + } + + #[test] + fn bounded_inputs_reject_strings_and_json_before_queueing() { + assert!(input_bytes(&json!("x".repeat(MAX_INPUT_BYTES + 1))).is_err()); + // String escaping expands an otherwise smaller tree beyond the limit. + assert!(input_bytes(&json!({"x": "\n".repeat(MAX_INPUT_BYTES / 2)})).is_err()); + assert_eq!(input_bytes(&json!("plain")).unwrap(), b"plain"); + assert_eq!(input_bytes(&json!({"n": 1})).unwrap(), br#"{"n":1}"#); + } + + #[test] + fn invocation_isolation_revocation_cancellation_and_queue_bounds() { + const CHILD: &str = "OCTOSENSE_TEST_WASM_ISOLATION"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wasm_service::tests::invocation_isolation_revocation_cancellation_and_queue_bounds", "--nocapture"]) + .env(CHILD, "1").output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let root = std::env::temp_dir().join(format!("wasm-isolation-{}", uuid::Uuid::new_v4())); + let host_dir = root.join(".host"); + octosense_appstore::set_data_root(root.clone()); + ship_state("os.wasmstate", "1.0.0", true); + register(); + // Real dispatch into a held cold queue must neither unpack nor verify + // a bundle on this (UI) thread. The old synchronous admission path + // creates .system here and populates the identity before returning. + let (cold_sender, cold_queue) = sync_channel(MAX_QUEUED_PER_APP); + let cold_epoch = Arc::new(AdmissionEpoch::default()); + WORKERS + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert( + "os.wasmstate".into(), + Worker { + jobs: cold_sender, + retired: Arc::new(AtomicBool::new(false)), + admission: cold_epoch.clone(), + }, + ); + let cold_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + cold_heap, + 1, + &mut NoSheet, + ); + assert!(cold_epoch.admitted.get().is_none()); + assert!( + !root.join(".system").exists(), + "enqueue must not touch bundle storage" + ); + let cold_job = cold_queue.try_recv().unwrap(); + assert!(process("os.wasmstate", &mut None, &cold_job).is_ok()); + assert!(cold_epoch.admitted.get().unwrap().is_ok()); + assert!( + root.join(".system").exists(), + "the worker performed actual admission" + ); + octosense_appstore::services::cancel_heap(cold_heap); + drop(cold_job); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(cold_queue); + // Positive control: this guest really retains input when an Instance + // is reused, reproducing the old host behavior rather than testing an + // inert fixture that could never leak. + let engine = runtime(&host_dir).unwrap(); + let program = engine.load(&wat::parse_str(STATE_GUEST).unwrap()).unwrap(); + let mut reused = engine.instantiate(&program).unwrap(); + reused.call("remember", b"private fixture input").unwrap(); + assert_eq!( + reused.call("recall", b"").unwrap(), + b"private fixture input" + ); + drop(reused); + for method in ["remember", "remember_fail"] { + let result = request( + "os.wasmstate", + &format!("wasm.{method}"), + json!("private fixture input"), + &host_dir, + ); + assert_eq!(result.is_ok(), method == "remember"); + let recalled = request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + assert_eq!( + recalled, + json!({"text":""}), + "no successful or failed call retains private input" + ); + } + + let mut lab = None; + let (heap, job) = captured_job("os.wasmstate", "recall", &host_dir); + octosense_appstore::services::cancel_heap(heap); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("cancelled")); + assert!( + lab.is_none(), + "cancelled queued call never loads or executes guest code" + ); + let (heap, mut job) = captured_job("os.wasmstate", "recall", &host_dir); + job.deadline = Instant::now(); + assert!(process("os.wasmstate", &mut lab, &job) + .unwrap_err() + .contains("deadline")); + assert!(lab.is_none()); + octosense_appstore::services::cancel_heap(heap); + + let (heap, stale) = captured_job("os.wasmstate", "recall", &host_dir); + ship_state("os.wasmstate", "2.0.0", true); + assert!(process("os.wasmstate", &mut lab, &stale) + .unwrap_err() + .contains("admission changed")); + assert!(lab.is_none(), "old queued revision cannot run after update"); + octosense_appstore::services::cancel_heap(heap); + let (heap, current) = captured_job("os.wasmstate", "recall", &host_dir); + assert_eq!( + process("os.wasmstate", &mut lab, ¤t).unwrap(), + json!({"text":"new"}), + "new revision executes its new bytes" + ); + assert!(lab.is_some()); + octosense_appstore::services::cancel_heap(heap); + + // Real registration/grants change exactly between calculation and + // delivery, so this fails if process omits its completion admission check. + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + let error = process_checked("os.wasmstate", &mut lab, &in_flight, || { + ship_state("os.wasmstate", "3.0.0", false) + }) + .unwrap_err(); + assert!(error.contains("not granted"), "{error}"); + assert!( + lab.is_none(), + "revocation drops cached Programs before delivery" + ); + octosense_appstore::services::cancel_heap(heap); + ship_state("os.wasmstate", "4.0.0", true); + let (heap, in_flight) = captured_job("os.wasmstate", "recall", &host_dir); + assert!(process_checked("os.wasmstate", &mut lab, &in_flight, || { + octosense_appstore::services::cancel_heap(heap) + }) + .is_err()); + assert!(octosense_appstore::services::take_replies_for(&[heap]).is_empty()); + + // Exercise the real service enqueue path against a deliberately held + // worker receiver: no timing-dependent slow guest is needed to fill it. + let (tx, held) = sync_channel(MAX_QUEUED_PER_APP); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + "os.wasmstate".into(), + Worker { + jobs: tx, + retired: Arc::new(AtomicBool::new(false)), + admission: Arc::new(AdmissionEpoch { + admitted: OnceLock::from(Ok(Admission::current("os.wasmstate").unwrap())), + obsolete: AtomicBool::new(false), + }), + }, + ); + let mut heaps = Vec::new(); + for i in 0..=MAX_QUEUED_PER_APP { + let heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + heaps.push(heap); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!({}), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + heap, + 1, + &mut NoSheet, + ); + let replies = octosense_appstore::services::take_replies_for(&[heap]); + if i < MAX_QUEUED_PER_APP { + assert!(replies.is_empty()); + } else { + assert!(replies[0].2.as_ref().unwrap_err().contains("queue is full")); + } + } + let queued = held.try_iter().collect::>(); + assert_eq!(queued.len(), MAX_QUEUED_PER_APP); + ship_state("os.wasmstate", "5.0.0", true); + for job in &queued { + assert!( + process("os.wasmstate", &mut lab, job) + .unwrap_err() + .contains("admission changed"), + "the actual dispatch queue retains its original verified revision" + ); + } + let retry_heap = NEXT_HEAP.fetch_add(1, Ordering::Relaxed); + octosense_appstore::services::dispatch( + ServiceCall { + app_id: "os.wasmstate".into(), + service: "wasm.recall".into(), + args: json!(null), + from_sheet: false, + may_prompt: false, + host_dir: host_dir.clone(), + }, + retry_heap, + 1, + &mut NoSheet, + ); + let retry = held.try_recv().unwrap(); + assert!(!Arc::ptr_eq(&retry.admission, &queued[0].admission)); + assert_eq!( + process("os.wasmstate", &mut lab, &retry).unwrap(), + json!({"text":""}), + "a fresh dispatched request re-admits the new revision on the same worker" + ); + octosense_appstore::services::cancel_heap(retry_heap); + drop(retry); + for heap in heaps { + octosense_appstore::services::cancel_heap(heap); + } + assert!(queued.iter().all(|job| !job.live())); + drop(queued); + let used = BUFFERED_BYTES.load(Ordering::Acquire); + assert!(Reservation::acquire(MAX_BUFFERED_BYTES - used).is_ok()); + let all = Reservation::acquire(MAX_BUFFERED_BYTES - used).unwrap(); + assert!(Reservation::acquire(1).is_err()); + drop(all); + assert_eq!(BUFFERED_BYTES.load(Ordering::Acquire), used); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + // The global limit is enforced even when one more admitted app has + // no existing queue. These receiver handles stand in for busy workers. + let mut held_workers = Vec::new(); + for n in 0..MAX_WORKERS { + let (jobs, queue) = sync_channel(MAX_QUEUED_PER_APP); + held_workers.push(queue); + WORKERS.lock().unwrap().as_mut().unwrap().insert( + format!("busy-{n}"), + Worker { + jobs, + // Closing workers still hold resources. They must not + // release a slot merely because retirement has started. + retired: Arc::new(AtomicBool::new(n == 0)), + admission: Arc::default(), + }, + ); + } + assert!( + request("os.wasmstate", "wasm.recall", json!(null), &host_dir) + .unwrap_err() + .contains("workers are busy") + ); + WORKERS.lock().unwrap().as_mut().unwrap().clear(); + drop(held_workers); + request("os.wasmstate", "wasm.recall", json!(null), &host_dir).unwrap(); + let idle_deadline = Instant::now() + WORKER_IDLE + Duration::from_secs(2); + while !WORKERS.lock().unwrap().as_ref().unwrap().is_empty() + && Instant::now() < idle_deadline + { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + WORKERS.lock().unwrap().as_ref().unwrap().is_empty(), + "idle workers release their cached modules and slot" + ); + + // A real signed store catalog withdrawal also vetoes an already + // calculated result. This exercises installed_bundle/may_run, not a + // fake admission checker or the system-app shortcut. + use octosense_app_hub::{Catalog, Entry, HubKey, Source, Status}; + let id = "org.example.wasm-isolation"; + let bundle = octosense_app_hub::installed_bundle_dir(&root, id); + std::fs::create_dir_all(bundle.join("fns")).unwrap(); + std::fs::write(bundle.join("main.splash"), "Label {text: \"Fixture\"}").unwrap(); + std::fs::write( + bundle.join("fns/state.wasm"), + wat::parse_str(STATE_GUEST).unwrap(), + ) + .unwrap(); + let mut manifest = octosense_app_contract::AppManifest::parse(&json!({ + "schema":1,"id":id,"version":"1.0.0","name":"Wasm isolation fixture", + "capabilities":["wasm"],"integrity":{"bundle_blake3":octosense_app_contract::digest_dir(&bundle).unwrap()} + }).to_string()).unwrap(); + let publisher = HubKey::generate(); + octosense_app_hub::sign_manifest(&publisher, &mut manifest, "fixture").unwrap(); + std::fs::write( + bundle.join("manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + let anchor = HubKey::generate(); + let working = HubKey::generate(); + let certificate = anchor.certify(&working.public_hex()).unwrap(); + std::env::set_var("OCTOSENSE_HUB_ANCHOR", anchor.public_hex()); + let entry = Entry { + manifest, + listing: None, + tools: vec![], + artifact: "fixture".into(), + publisher: "fixture".into(), + publisher_key: publisher.public_hex(), + source: Source { + repository: String::new(), + commit: String::new(), + }, + status: Status::Offered, + admitted: "2026-10-08".into(), + }; + let mut catalog = Catalog::new(1, "2026-10-08", vec![entry]); + let write_catalog = |catalog: &mut Catalog| { + working.sign_catalog(catalog, &certificate).unwrap(); + std::fs::write( + root.join("catalog.json"), + serde_json::to_vec(catalog).unwrap(), + ) + .unwrap(); + }; + write_catalog(&mut catalog); + let (heap, in_flight) = captured_job(id, "recall", &host_dir); + let mut installed_lab = None; + let error = process_checked(id, &mut installed_lab, &in_flight, || { + catalog.sequence += 1; + catalog.entries[0].status = Status::Withdrawn("fixture withdrawal".into()); + write_catalog(&mut catalog); + }) + .unwrap_err(); + assert!(error.contains("withdrawn"), "{error}"); + assert!(installed_lab.is_none()); + assert!(Admission::current(id).is_err()); + octosense_appstore::services::cancel_heap(heap); + let _ = std::fs::remove_dir_all(root); + } + /// The whole path, in a process of its own (the apps root and the /// registries are process-wide): Wasm Lab's tools reach its functions /// through the executor and the service, its script's requests do too, @@ -336,14 +1138,24 @@ mod tests { const CHILD: &str = "OCTOSENSE_TEST_WASM_SERVICE"; if std::env::var_os(CHILD).is_none() { let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", "--nocapture"]) + .args([ + "--exact", + "wasm_service::tests::the_apps_own_functions_answer_its_tools_and_its_script", + "--nocapture", + ]) .env(CHILD, "1") .output() .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); return; } - let root = std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); + let root = + std::env::temp_dir().join(format!("octosense-wasm-service-{}", std::process::id())); let host_dir = root.join(".host"); octosense_appstore::set_data_root(root.clone()); ship("os.wasmlab", "service", |_, _| {}); @@ -372,7 +1184,10 @@ mod tests { "name": "wasmlab.find_slots", "args": {"day_start": "09:00", "day_end": "12:00", "duration": 30, "busy": [["09:30", "11:00"]]}})) .unwrap(); - executor.execute(call, ToolReply::new("c1", move |v| into.lock().unwrap().push(v))); + executor.execute( + call, + ToolReply::new("c1", move |v| into.lock().unwrap().push(v)), + ); for _ in 0..1000 { script_apps::poll(); if !sent.lock().unwrap().is_empty() { @@ -383,42 +1198,108 @@ mod tests { let answer = sent.lock().unwrap().pop().expect("the tool answered"); assert_eq!(answer["ok"], true, "{answer}"); // Every 15 minutes, the default step. - assert_eq!(answer["data"]["slots"], json!([["09:00", "09:30"], ["11:00", "11:30"], ["11:15", "11:45"], ["11:30", "12:00"]]), "{answer}"); + assert_eq!( + answer["data"]["slots"], + json!([ + ["09:00", "09:30"], + ["11:00", "11:30"], + ["11:15", "11:45"], + ["11:30", "12:00"] + ]), + "{answer}" + ); // The script's own requests: a string goes in as its text, and an // output that is not JSON comes back as text. let html = request("os.wasmlab", "wasm.md_to_html", json!("# Hi"), &host_dir).unwrap(); assert_eq!(html["text"], "

Hi

\n"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "cal", "items": ["Mail", "Calendar"]}), &host_dir).unwrap(); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "cal", "items": ["Mail", "Calendar"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Calendar"); - let error = request("os.wasmlab", "wasm.find_slots", json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), &host_dir).unwrap_err(); + let error = request( + "os.wasmlab", + "wasm.find_slots", + json!({"day_start": "12:00", "day_end": "09:00", "duration": 30, "busy": []}), + &host_dir, + ) + .unwrap_err(); assert!(error.contains("ends before it starts"), "{error}"); let error = request("os.wasmlab", "wasm.nothing", json!({}), &host_dir).unwrap_err(); assert!(error.contains("no function"), "{error}"); // Rogue code: an error each time, and the next call still answers. for mode in ["loop", "alloc", "panic", "recurse"] { - let error = request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); - assert!(error.contains("deadline") || error.contains("trapped"), "{mode}: {error}"); - let ranked = request("os.wasmlab", "wasm.fuzzy_rank", json!({"query": "m", "items": ["Mail"]}), &host_dir).unwrap(); + let error = + request("os.wasmlab", "wasm.rogue", json!({"mode": mode}), &host_dir).unwrap_err(); + assert!( + error.contains("deadline") || error.contains("trapped"), + "{mode}: {error}" + ); + let ranked = request( + "os.wasmlab", + "wasm.fuzzy_rank", + json!({"query": "m", "items": ["Mail"]}), + &host_dir, + ) + .unwrap(); assert_eq!(ranked["ranked"][0]["item"], "Mail", "{mode}"); } let described = request("os.wasmlab", "wasm.functions", json!({}), &host_dir).unwrap(); - assert_eq!(described["functions"], json!(["find_slots", "fuzzy_rank", "md_to_html", "rogue", "text_diff"])); + assert_eq!( + described["functions"], + json!([ + "find_slots", + "fuzzy_rank", + "md_to_html", + "rogue", + "text_diff" + ]) + ); assert_eq!(described["modules"][0]["file"], "wasmlab.wasm"); - assert_eq!(described["modules"][0]["renewed"], 4); + assert_eq!(described["modules"][0]["instance_policy"], "fresh-per-call"); assert_eq!(described["stats"]["rogue"]["errors"], 4); - assert!(host_dir.join("wasm-cache").read_dir().unwrap().next().is_some(), "compiled code is cached"); + assert!( + host_dir + .join("wasm-cache") + .read_dir() + .unwrap() + .next() + .is_some(), + "compiled code is cached" + ); // No grant, no functions: the service checks the admitted manifest // itself, whoever dispatched the request. - let error = request("os.wasmplain", "wasm.fuzzy_rank", json!({"query": "m", "items": []}), &host_dir).unwrap_err(); - assert!(error.contains("was not granted the wasm service"), "{error}"); + let error = request( + "os.wasmplain", + "wasm.fuzzy_rank", + json!({"query": "m", "items": []}), + &host_dir, + ) + .unwrap_err(); + assert!( + error.contains("was not granted the wasm service"), + "{error}" + ); // And a sheet never reaches it. let heap = NEXT_HEAP.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let call = ServiceCall { app_id: "os.wasmlab".into(), service: "wasm.functions".into(), args: json!({}), from_sheet: true, may_prompt: true, host_dir: host_dir.clone() }; + let call = ServiceCall { + app_id: "os.wasmlab".into(), + service: "wasm.functions".into(), + args: json!({}), + from_sheet: true, + may_prompt: true, + host_dir: host_dir.clone(), + }; octosense_appstore::services::dispatch(call, heap, 1, &mut NoSheet); - let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]).pop().unwrap(); + let (_, _, answer) = octosense_appstore::services::take_replies_for(&[heap]) + .pop() + .unwrap(); assert!(answer.unwrap_err().contains("no sheet")); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/wasm-host/examples/encode_phone_fixture.rs b/crates/wasm-host/examples/encode_phone_fixture.rs new file mode 100644 index 00000000..beba148d --- /dev/null +++ b/crates/wasm-host/examples/encode_phone_fixture.rs @@ -0,0 +1,4 @@ +fn main() { + let a: Vec<_> = std::env::args().collect(); + std::fs::write(&a[2], wat::parse_file(&a[1]).unwrap()).unwrap(); +} diff --git a/crates/wasm-host/src/lib.rs b/crates/wasm-host/src/lib.rs index 364fa47b..4cbebf28 100644 --- a/crates/wasm-host/src/lib.rs +++ b/crates/wasm-host/src/lib.rs @@ -28,8 +28,9 @@ use std::collections::HashMap; use std::fmt; use std::hash::{Hash, Hasher}; +use std::io::Write; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -55,6 +56,8 @@ pub struct Limits { pub deadline: Duration, /// The most linear memory an instance may have. pub memory_bytes: usize, + /// The most elements in an instance's single table (including growth). + pub table_elements: usize, /// The largest module the runtime loads. pub module_bytes: usize, /// The largest input, and the largest output, of a call. @@ -68,6 +71,7 @@ impl Default for Limits { Limits { deadline: Duration::from_secs(2), memory_bytes: 256 << 20, + table_elements: 16_384, module_bytes: 8 << 20, io_bytes: 16 << 20, stack_bytes: 512 << 10, @@ -172,6 +176,16 @@ struct State { logs: Vec, } +struct InvocationGuard { + deadline: Instant, + pending: Box bool + Send + Sync>, +} +impl InvocationGuard { + fn live(&self) -> bool { + Instant::now() < self.deadline && (self.pending)() + } +} + /// One app's instance of a [`Program`]: its own memory and its own state. /// It may move to another thread. pub struct Instance { @@ -186,6 +200,7 @@ pub struct Instance { /// be half-updated then (a Rust guest's stack pointer and allocator live /// in its memory and globals), so the instance takes no more calls. spent: bool, + guard: Option>, _ticker: Arc, } @@ -245,8 +260,25 @@ impl Runtime { // Check before caching: a refused module is never stored. check(&module)?; if let Ok(code) = module.serialize() { - let tmp = path.with_extension("tmp"); - if std::fs::write(&tmp, &code).is_ok() && std::fs::rename(&tmp, path).is_ok() { + // Different apps may compile identical bytes concurrently. + // Never truncate another worker's staging file: deserialization + // is only safe for complete, unmodified serialized modules. + static NEXT_CACHE_WRITE: AtomicU64 = AtomicU64::new(0); + let tmp = path.with_extension(format!( + "{}-{}.tmp", + std::process::id(), + NEXT_CACHE_WRITE.fetch_add(1, Ordering::Relaxed) + )); + let published = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + file.write_all(&code)?; + drop(file); + std::fs::rename(&tmp, path) + })(); + if published.is_ok() { // Read it back off this thread: an on-access scanner // (Microsoft Defender's, on a managed Mac) holds the // first open of a new file for about a second, so let @@ -256,6 +288,7 @@ impl Runtime { .name("wasm-cache".into()) .spawn(move || drop(std::fs::read(path))); } + let _ = std::fs::remove_file(tmp); } } Ok(module) @@ -275,8 +308,38 @@ impl Runtime { /// A fresh instance of `program`, with its own memory. pub fn instantiate(&self, program: &Program) -> Result { + self.instantiate_inner(program, None) + } + + /// A fresh invocation whose start function and exported call share a + /// deadline. Cancellation is checked before starting and every epoch; + /// compiled code is reusable, but none of the instance's state is shared. + pub fn instantiate_guarded( + &self, + program: &Program, + deadline: Instant, + pending: impl Fn() -> bool + Send + Sync + 'static, + ) -> Result { + let deadline = deadline.min(Instant::now() + self.limits.deadline); + self.instantiate_inner(program, Some((deadline, Box::new(pending)))) + } + + fn instantiate_inner( + &self, + program: &Program, + guard: Option<(Instant, Box bool + Send + Sync>)>, + ) -> Result { + if guard + .as_ref() + .is_some_and(|(deadline, pending)| Instant::now() >= *deadline || !pending()) + { + return Err(LoadError::Invalid( + "invocation cancelled or past its deadline".into(), + )); + } let limits = StoreLimitsBuilder::new() .memory_size(self.limits.memory_bytes) + .table_elements(self.limits.table_elements) .instances(1) .memories(1) .tables(1) @@ -290,7 +353,19 @@ impl Runtime { }, ); store.limiter(|state| &mut state.limits); - store.epoch_deadline_trap(); + let guard = + guard.map(|(deadline, pending)| Arc::new(InvocationGuard { deadline, pending })); + if let Some(guard) = guard.clone() { + store.epoch_deadline_callback(move |_| { + Ok(if !guard.live() { + wasmtime::UpdateDeadline::Interrupt + } else { + wasmtime::UpdateDeadline::Continue(1) + }) + }); + } else { + store.epoch_deadline_trap(); + } // Instantiation runs the start function and data initialisers: a // deadline covers it too. let deadline_ticks = self @@ -299,7 +374,7 @@ impl Runtime { .as_millis() .div_ceil(TICK.as_millis()) .max(1) as u64; - store.set_epoch_deadline(deadline_ticks); + store.set_epoch_deadline(if guard.is_some() { 1 } else { deadline_ticks }); let mut linker = Linker::new(&self.engine); linker .func_wrap( @@ -349,6 +424,7 @@ impl Runtime { deadline_ticks, io_bytes: self.limits.io_bytes, spent: false, + guard, _ticker: self.ticker.clone(), }) } @@ -449,6 +525,10 @@ impl Instance { if self.spent { return Err(CallError::Spent); } + if self.guard.as_ref().is_some_and(|guard| !guard.live()) { + self.spent = true; + return Err(CallError::Deadline); + } let f = self .functions .get(name) @@ -470,7 +550,11 @@ impl Instance { } fn run(&mut self, f: TypedFunc<(i32, i32), i64>, input: &[u8]) -> Result, CallError> { - self.store.set_epoch_deadline(self.deadline_ticks); + self.store.set_epoch_deadline(if self.guard.is_some() { + 1 + } else { + self.deadline_ticks + }); let len = input.len() as i32; let ptr = self.alloc.call(&mut self.store, len).map_err(trap)?; self.memory diff --git a/crates/wasm-host/tests/runtime.rs b/crates/wasm-host/tests/runtime.rs index 0bf449b7..60e46114 100644 --- a/crates/wasm-host/tests/runtime.rs +++ b/crates/wasm-host/tests/runtime.rs @@ -116,7 +116,10 @@ fn a_function_gets_its_input_and_returns_its_output() { fn a_guest_error_comes_back_as_its_text_and_the_instance_goes_on() { let runtime = Runtime::new(limits(), None).unwrap(); let mut guest = instance(&runtime); - assert_eq!(guest.call("fail", b""), Err(CallError::Guest("nope".into()))); + assert_eq!( + guest.call("fail", b""), + Err(CallError::Guest("nope".into())) + ); assert!(!guest.spent()); assert_eq!(guest.call("upper", b"still here").unwrap(), b"STILL HERE"); } @@ -253,3 +256,140 @@ fn compiled_code_is_cached_by_digest() { ); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn table_initial_size_and_growth_have_an_element_cap() { + let runtime = Runtime::new( + Limits { + table_elements: 8, + ..limits() + }, + None, + ) + .unwrap(); + let guest = |initial: usize| { + wat::parse_str(format!( + r#"(module + (memory (export "memory") 1) (table {initial} funcref) + (func (export "octo_alloc") (param i32) (result i32) (i32.const 0)) + (func (export "octo_free") (param i32 i32)) + (func (export "grow") (param i32 i32) (result i64) + (drop (table.grow (ref.null func) (i32.const 9))) (i64.const 1)))"# + )) + .unwrap() + }; + let program = runtime.load(&guest(9)).unwrap(); + assert!( + runtime.instantiate(&program).is_err(), + "initial table must respect the cap" + ); + let program = runtime.load(&guest(1)).unwrap(); + let error = runtime + .instantiate(&program) + .unwrap() + .call("grow", b"") + .unwrap_err(); + assert!(matches!(error, CallError::Trap(_)), "{error}"); +} + +#[test] +fn guarded_invocations_observe_cancellation_and_the_absolute_deadline() { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + let runtime = Runtime::new( + Limits { + deadline: Duration::from_secs(2), + ..limits() + }, + None, + ) + .unwrap(); + let program = runtime.load(&wat::parse_str(GUEST).unwrap()).unwrap(); + assert!(runtime + .instantiate_guarded(&program, Instant::now(), || true) + .is_err()); + assert!(runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_secs(2), || false) + .is_err()); + let pending = Arc::new(AtomicBool::new(true)); + let check = pending.clone(); + let mut instance = runtime + .instantiate_guarded( + &program, + Instant::now() + Duration::from_secs(2), + move || check.load(Ordering::Acquire), + ) + .unwrap(); + let cancel = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(40)); + pending.store(false, Ordering::Release); + }); + let started = Instant::now(); + assert_eq!(instance.call("spin", b""), Err(CallError::Deadline)); + assert!( + started.elapsed() < Duration::from_secs(1), + "cancellation interrupts active guest code" + ); + cancel.join().unwrap(); + let mut instance = runtime + .instantiate_guarded(&program, Instant::now() + Duration::from_millis(30), || { + true + }) + .unwrap(); + std::thread::sleep(Duration::from_millis(40)); + assert_eq!( + instance.call("spin", b""), + Err(CallError::Deadline), + "call does not restart an expired budget" + ); +} + +#[test] +fn concurrent_compilation_publishes_only_complete_cache_entries() { + use std::sync::{Arc, Barrier}; + let dir = + std::env::temp_dir().join(format!("octosense-wasm-cache-race-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let runtime = Arc::new(Runtime::new(limits(), Some(dir.clone())).unwrap()); + let bytes = Arc::new(wat::parse_str(GUEST).unwrap()); + let start = Arc::new(Barrier::new(4)); + let workers: Vec<_> = (0..4) + .map(|_| { + let (runtime, bytes, start) = (runtime.clone(), bytes.clone(), start.clone()); + std::thread::spawn(move || { + start.wait(); + let program = runtime.load(&bytes).unwrap(); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"parallel") + .unwrap(), + b"PARALLEL" + ); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + let program = runtime.load(&bytes).unwrap(); + assert!(program.from_cache()); + assert_eq!( + runtime + .instantiate(&program) + .unwrap() + .call("upper", b"cached") + .unwrap(), + b"CACHED" + ); + assert!(std::fs::read_dir(&dir).unwrap().all(|entry| entry + .unwrap() + .path() + .extension() + .unwrap() + == "cwasm")); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/wasm-phone-smoke/Cargo.toml b/crates/wasm-phone-smoke/Cargo.toml new file mode 100644 index 00000000..9ca493a1 --- /dev/null +++ b/crates/wasm-phone-smoke/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "octosense-wasm-phone-smoke" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +# Android needs a library entry point. The fixture uses the normal signed +# Store, script-tool dispatcher and Wasm service without alternate grants. +[lib] +path = "src/main.rs" +test = false +bench = false +doctest = false + +[[bin]] +name = "octosense-wasm-phone-smoke" +path = "src/main.rs" + +[features] +default = ["acceptance-fixtures"] +acceptance-fixtures = ["octosense-shell/acceptance-fixtures", "octosense-shell/wasm-lab"] + +[dependencies] +makepad-widgets.workspace = true +makepad-app-module.workspace = true +octosense-appstore.workspace = true +octosense-app-hub.workspace = true +octosense-app-policy.workspace = true +octosense-shell.workspace = true +serde_json.workspace = true + +[build-dependencies] +serde_json.workspace = true diff --git a/crates/wasm-phone-smoke/build.rs b/crates/wasm-phone-smoke/build.rs new file mode 100644 index 00000000..9e1c0412 --- /dev/null +++ b/crates/wasm-phone-smoke/build.rs @@ -0,0 +1,67 @@ +use std::{env, fs, path::Path, process::Command}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "Cannot read fixture source identity" + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn main() { + let manifest = env::var_os("CARGO_MANIFEST_DIR").unwrap(); + let root = Path::new(&manifest).ancestors().nth(2).unwrap(); + let revision = git(root, &["rev-parse", "HEAD"]); + let runtime = root.join(".sources/makepad"); + let lock: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("runtime-patches.lock.json")).unwrap()).unwrap(); + let tree = git(&runtime, &["write-tree"]); + assert_eq!( + Some(tree.as_str()), + lock["makepad"]["tree"].as_str(), + "Prepare the pinned runtime before building the fixture" + ); + let runtime_clean = Command::new("git") + .args(["diff", "--quiet", "--"]) + .current_dir(&runtime) + .status() + .unwrap() + .success(); + let dirty = !runtime_clean + || !git(root, &["status", "--porcelain", "--untracked-files=normal"]).is_empty(); + println!("cargo:rustc-env=WASM_FIXTURE_REVISION={revision}"); + println!("cargo:rustc-env=WASM_FIXTURE_RUNTIME_TREE={tree}"); + println!("cargo:rustc-env=WASM_FIXTURE_DIRTY={dirty}"); + for path in [ + "src", + "build.rs", + "../shell/examples/connected_support/mod.rs", + "../../runtime-patches.lock.json", + "../../.sources/makepad/widgets/src/splash_host.rs", + ] { + println!("cargo:rerun-if-changed={path}"); + } + for name in ["HEAD", "index"] { + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name]) + ); + } + let symbolic = Command::new("git") + .args(["symbolic-ref", "-q", "HEAD"]) + .current_dir(root) + .output() + .unwrap(); + if symbolic.status.success() { + let name = String::from_utf8(symbolic.stdout).unwrap(); + println!( + "cargo:rerun-if-changed={}", + git(root, &["rev-parse", "--git-path", name.trim()]) + ); + } +} diff --git a/crates/wasm-phone-smoke/src/main.rs b/crates/wasm-phone-smoke/src/main.rs new file mode 100644 index 00000000..c64b899e --- /dev/null +++ b/crates/wasm-phone-smoke/src/main.rs @@ -0,0 +1,235 @@ +//! Synthetic signed-app Wasm containment and raw-response acceptance host. +//! Uses normal Store admission, the script-tool dispatcher and Wasm service. +//! It does not start a model, use an account or confer device consent. +use makepad_app_module::AppModule; +use makepad_widgets::*; +use octosense_appstore::{host_api, script_tools, services}; +use serde_json::{json, Value}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; +#[path = "../../shell/examples/connected_support/mod.rs"] +mod connected_support; +app_main!(App, font_set: International); + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + startup() do #(App::script_component(vm)) { + ui: Root { main_window := Window { + window.inner_size: vec2(640, 650) + body +: {flow: Overlay + app := Splash {width: Fill height: Fill} + sheet := Splash {width: Fill height: Fill visible: false} + } + }} + } +} + +#[derive(Script, ScriptHook)] +struct App { + #[live] + ui: WidgetRef, + #[rust] + app: String, + #[rust] + root: PathBuf, + #[rust] + receipt: Option, + #[rust] + launch: Option, + #[rust] + assets: Option, + #[rust] + registration: Option, + #[rust] + card: SplashRef, + #[rust] + sheet: SplashRef, + #[rust] + timer: Option, + #[rust] + replies: Arc>>>, + #[rust] + preliminary: Value, + #[rust] + admitted_prompts: bool, + #[rust] + suspended: bool, +} + +impl MatchEvent for App { + fn handle_startup(&mut self, cx: &mut Cx) { + #[cfg(not(target_os = "android"))] + let arg = + |name: &str| std::env::args().find_map(|a| a.strip_prefix(name).map(str::to_owned)); + #[cfg(not(target_os = "android"))] + let root = + PathBuf::from(arg("--app-data=").expect("Pass an isolated --app-data directory")); + #[cfg(not(target_os = "android"))] + let bundle = PathBuf::from(arg("--bundle=").expect("Pass --bundle=")); + #[cfg(target_os = "android")] + let data = PathBuf::from(cx.get_data_dir().expect("Android private data directory")) + .join("wasm-phone-lab"); + #[cfg(target_os = "android")] + let (root, bundle) = (data.join("apps"), data.join("bundle")); + let preview = std::env::args().any(|a| a == "--preview"); + octosense_appstore::set_data_root(root.clone()); + let policy = if preview { + let manifest = std::fs::read_to_string(bundle.join("manifest.json")).unwrap(); + let digest = octosense_app_policy::digest_dir(&bundle).unwrap(); + octosense_app_policy::admit_and_resolve_dir( + &manifest, + &digest, + &octosense_app_policy::HostLimits::default().with_require_signature(false), + &octosense_app_policy::RefuseAllSignatures, + ) + .expect("Preview admission") + } else { + let installed = + connected_support::install(&[bundle.clone()], &root).expect("Signed installation"); + std::env::set_var( + "OCTOSENSE_HUB_ANCHOR", + installed["anchor"].as_str().unwrap(), + ); + let launch = connected_support::open(&root, "org.octosense.samples.wasmprobe").unwrap(); + let policy = launch.policy.clone(); + self.launch = Some(launch); + policy + }; + assert_eq!(policy.app_id, "org.octosense.samples.wasmprobe"); + self.app = policy.app_id.clone(); + self.root = root.join(".host"); + let bundle = self + .launch + .as_ref() + .map(|l| l.bundle().to_path_buf()) + .unwrap_or(bundle); + let mut settings = policy.isolate_settings(&root); + self.admitted_prompts = settings.host_prompts; + std::fs::create_dir_all(&settings.jail_root).unwrap(); + let assets = octosense_app_policy::AssetServer::start_with_static(&bundle, &[]).unwrap(); + settings.hosts.push(assets.allowlist_entry()); + let source = octosense_app_policy::script_source(&bundle, assets.origin()) + .unwrap() + .unwrap(); + self.card = self.ui.splash(cx, ids!(app)); + self.sheet = self.ui.splash(cx, ids!(sheet)); + octosense_app_policy::splash_adapter::apply(&self.card, cx, &settings); + octosense_appstore::apply_device_consent(cx, &bundle, &self.card).unwrap(); + self.card.set_text(cx, &source); + self.assets = Some(assets); + self.registration = script_tools::bind(cx, &self.app, &bundle, &self.card).unwrap(); + self.timer = Some(cx.start_interval(0.05)); + if preview { + return; + } + #[cfg(not(target_os = "android"))] + let receipt = PathBuf::from(arg("--receipt=").expect("Pass a new --receipt file")); + #[cfg(target_os = "android")] + let receipt = data.join("receipt.json"); + self.receipt = Some(receipt); + assert!( + !self.receipt.as_ref().unwrap().exists(), + "Receipt must be a new file" + ); + let submit = |name, args, account| { + script_tools::submit( + &self.app, + name, + args, + account, + "native-acceptance", + Duration::from_secs(15), + Box::new(|_| {}), + ) + }; + let wrong_account = submit("wasmprobe.inspect", json!({}), "other-account").unwrap_err(); + let undeclared = submit("wasmprobe.not_declared", json!({}), "device").unwrap_err(); + let invalid_args = + submit("wasmprobe.inspect", json!({"injected":true}), "device").unwrap_err(); + self.preliminary = json!({"wrong_account":wrong_account,"undeclared_tool":undeclared,"invalid_arguments":invalid_args}); + let out = self.replies.clone(); + script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(15), + Box::new(move |r| out.lock().unwrap().push(r)), + ) + .unwrap(); + } +} + +impl AppMain for App { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + makepad_widgets::theme_mod(vm); + script_eval!(vm, {mod.theme = mod.themes.light}); + makepad_widgets::widgets_mod(vm); + widget_async::set_splash_theme(widget_async::SplashTheme::Light); + octosense_appstore::cardapp::CARD_MODULE.register(vm); + octosense_shell::platform_services::register(); + octosense_shell::wasm_service::register(); + self::script_mod(vm) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + self.match_event(cx, event); + if matches!(event, Event::Pause | Event::Background) { + self.suspended = true; + } + if matches!(event, Event::Resume | Event::Foreground) { + self.suspended = false; + } + // Match the production CardAppView baseline; the test must not hide + // provenance bugs by leaving this false forever after its first tool. + let may_prompt = self.admitted_prompts + && !self.suspended + && !script_tools::pending_for(cx, &self.app, &self.card); + self.card.set_host_prompts(cx, may_prompt); + let sheet_up = self.sheet.borrow().is_some_and(|s| s.view.visible); + if sheet_up && services::is_sheet_input_event(event) { + self.sheet.handle_event(cx, event, &mut Scope::empty()); + } else { + self.ui.handle_event(cx, event, &mut Scope::empty()); + } + script_tools::pump(cx, &self.app, &self.card); + services::pump(cx, &self.app, &self.root, &self.card, &self.sheet); + octosense_shell::platform_services::handle_event(cx, event); + let reply = self.replies.lock().unwrap().pop(); + if let Some(reply) = reply { + let path = self.receipt.take().expect("One acceptance reply"); + self.registration = None; + let closed = script_tools::submit( + &self.app, + "wasmprobe.inspect", + json!({}), + "device", + "native-acceptance", + Duration::from_secs(1), + Box::new(|_| {}), + ) + .unwrap_err(); + let result = json!({"schema":1,"platform":host_api::platform(), + "proof":"signed app -> app_tool -> host.request -> isolated Wasm invocation", + "signed_install":true,"source_revision":env!("WASM_FIXTURE_REVISION"), + "source_dirty":env!("WASM_FIXTURE_DIRTY") != "false", + "runtime_tree":env!("WASM_FIXTURE_RUNTIME_TREE"),"bundle_digest":self.launch.as_ref().unwrap().manifest.integrity.bundle_blake3, + "tool_result":reply,"refusals":self.preliminary,"closed_app":closed, + "host_sheet_visible":self.sheet.borrow().is_some_and(|s| s.view.visible), + "not_verified":["live model or peer relay", "performance benchmark", "release APK"]}); + use std::io::Write; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .unwrap() + .write_all(&serde_json::to_vec_pretty(&result).unwrap()) + .unwrap(); + println!("HOST_API_LAB_COMPLETE"); + } + } +} diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.md b/docs/adr/0011-apps-own-functions-in-webassembly.md index 241d69f6..fdedbe55 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.md @@ -37,9 +37,9 @@ outside their own input and output. Cranelift when they load. Compiled code is cached in the host directory, keyed by the module's digest and the engine's compatibility hash. Every call has a deadline (2 s, epoch interruption against wall-clock time); - memory (256 MiB), the wasm stack (512 KiB), the module (8 MiB) and input - and output (16 MiB) have caps. A trap or a deadline ends the call with an - error, never the process, and spends the instance: the next call gets a + memory (256 MiB), table elements (16,384), the wasm stack (512 KiB), + the module (8 MiB) and runtime input/output (16 MiB) have caps. A trap or + a deadline ends the call with an error, never the process, and spends the instance: the next call gets a fresh one, because a Rust guest's stack pointer and allocator may be half-updated. 3. **The `wasm` host service** (shell feature `wasm-lab`). `wasm.` @@ -48,8 +48,31 @@ outside their own input and output. anything else as JSON; JSON output comes back as data, anything else as `{"text": …}`. Modules come only from the calling app's own admitted, digest-checked bundle, never from an argument, and the service checks the - admitted manifest's grant itself. Each app has one worker thread, so no - call runs on the UI thread and a slow function holds up only its own app. + admitted manifest's grant itself. Each invocation gets a fresh instance, + including after successful calls and guest-returned errors: memory, globals, + tables and logs cannot carry one caller's input into another call. Only + compiled Programs are reused. The low-level runtime's reusable `Instance` + API remains available for benchmarks; the app service never reuses it. + + The service admits at most four active workers, four queued requests per + app, 1 MiB serialized input per request and 16 MiB total buffered input + (including executing calls). Full queues return an error without blocking + the UI. Workers release Programs and exit after five idle seconds. A + request has a ten-second budget including queueing/loading; guest start + and function execution share at most two seconds, with cancellation + checked at each epoch. Compilation itself is not interruptible, but an + expired or cancelled request cannot proceed from compilation to execution. + Queueing clones a host-owned admission identity; it does no bundle IO, + signature verification or digest work on the UI thread. The first cold + request binds that identity when the worker first verifies admission: + `ServiceCall` does not carry the originating UI's loaded-revision token. + Later queued requests retain that verified revision. The worker rechecks + admission before execution and before delivery; changed bundles/grants or + signed withdrawal discard the result and cached Program references, and + invalidate the old identity. A fresh request can then load the new code; + already queued old requests remain refused. A closing worker keeps its + global slot until its queue and compiled Programs have been dropped. + The disk cache contains compiled code only, never guest state. 4. **The `wasm` capability** joins App Hub's closed capability list. A store says: "Runs its own functions in a sandbox on this device; they reach no files, network or other apps." @@ -78,6 +101,9 @@ CoreMark on the M5 Max (higher is faster; native Rust is about 45,000): ## Measurements (Wasm Lab, `apps/wasmlab`) +These are the original runtime measurements, before the service switched to +fresh instances for every call. They are not a new end-to-end service benchmark. + Light algorithms only: Markdown to HTML (pulldown-cmark), free slots around busy times, fuzzy ranking (strsim), a line diff (similar). @@ -146,12 +172,14 @@ Two platform findings: (about 17× slower than Cranelift). A shipped system app's functions could be translated to native code when the app is built; a store app's could not. OpenHarmony's JIT policy is unknown (**unverified**). -- The limits are per call and per instance. Nothing yet stops an app from - keeping one core busy with back-to-back calls, or from using 256 MiB in - each of its eight modules; a per-app CPU and memory budget is follow-up - work. A request that App Hub has timed out still runs in the worker. -- An app updated while the shell runs keeps its old functions until the - shell restarts; workers live as long as the shell. +- Guest memory is no longer retained across calls or across the eight modules: + each worker holds at most one live instance, and only four workers may run. + Continuous admitted requests can still keep those workers busy; CPU fairness + across applications and a bounded disk-cache eviction policy remain follow-up + work. Compiled Programs also consume host memory beyond the linear-memory cap. +- Runtime and shell service regression tests cover actual Wasm state retention, + table growth, cancellation, input/queue bounds, revision/grant changes and + signed withdrawal. This hardening has not been revalidated on a phone yet. ## Open questions diff --git a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md index 0c1bec3d..76eba01c 100644 --- a/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md +++ b/docs/adr/0011-apps-own-functions-in-webassembly.zh-CN.md @@ -26,15 +26,29 @@ Rust 写:解析器、排序或排程算法、差异比较、格式转换,或 serde 类型生成这一切。这一版不用组件模型,也不用 WASI。 2. **运行时。** `crates/wasm-host` 在 Wasmtime 49 中运行模块,模块加载时由 Cranelift 编译。编译结果缓存在宿主目录中,以模块摘要和引擎兼容性哈希为键。每次调用都有截止时间 - (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、wasm 栈(512 KiB)、模块 + (2 秒,按实际经过的时间做 epoch 中断);内存(256 MiB)、表元素(16,384 个)、wasm 栈(512 KiB)、模块 (8 MiB)以及输入输出(16 MiB)都有上限。陷阱或超时只让这次调用出错,绝不结束进程, 并作废这个实例:下次调用会得到新实例,因为 Rust 客体的栈指针和分配器可能只更新了一半。 3. **`wasm` 宿主服务**(外壳特性 `wasm-lab`)。`wasm.` 调用发起请求的应用自己的 - 函数;`wasm.functions` 列出这些函数、它们的加载情况和运行统计。字符串参数按文本传入, - 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回为 `{"text": …}`。模块只来自 - 发起请求的应用自己那个已准入、经过摘要校验的应用包,绝不来自参数;服务还会自己检查已准入 - 清单中的授权。每个应用有一个工作线程,所以没有调用在 UI 线程上运行,一个慢函数只会拖慢 - 它自己的应用。 + 函数;`wasm.functions` 列出这些函数、加载情况和运行统计。字符串参数按文本传入, + 其他参数按 JSON 传入;JSON 输出作为数据返回,其他输出返回 `{"text": …}`。 + 模块只来自调用应用自己通过准入、经过摘要检查的应用包,而不是请求参数指定的文件。 + 每次调用都使用全新实例,包括上次成功或返回客体错误的情况;内存、全局变量、表和 + 日志不能把某次调用的输入带到下一次。只复用编译后的 Program。底层运行时仍为基准 + 测试提供可复用的 `Instance` API,但应用服务不复用实例。 + + 服务最多允许四个活动工作线程,每个应用最多排队四个请求;每个请求序列化后的输入 + 不超过 1 MiB,所有排队及正在执行的输入合计不超过 16 MiB。队列已满时立即返回错误, + 不阻塞界面线程。工作线程空闲五秒后释放 Program 并退出。每个请求从排队到加载有 + 十秒总预算;客体启动函数与导出函数共享最多两秒的执行预算,每次 epoch 检查取消。 + 编译本身不能被中断,但过期或已取消的请求不会在编译后继续执行。 + 入队只复制宿主持有的准入身份引用,不在界面线程读取应用包、验签或计算摘要。 + 第一次冷启动请求在工作线程首次验证准入时绑定身份;`ServiceCall` 并不携带发起 + 请求的界面已加载版本的令牌。后续排队请求保留该已验证版本。工作线程在执行前和 + 交付结果前重新检查准入;应用包、权限发生变化或签名目录撤回应用时,丢弃结果和 + 缓存的 Program 引用,并使旧身份失效。此后的新请求可以加载新代码;已经排队的 + 旧请求仍会被拒绝。正在关闭的工作线程在释放队列和编译模块之前继续占用全局名额。 + 磁盘缓存只包含编译代码,不包含客体状态。 4. **`wasm` 能力**加入 App Hub 封闭的能力列表。商店的说明是:“在此设备上的沙盒中运行它 自带的函数;这些函数接触不到文件、网络或其他应用。” 5. **Agent 工具。** 工具可以写 `host_method: "wasm."`:应用的 Agent(以及在 @@ -60,6 +74,9 @@ M5 Max 上的 CoreMark(越高越快;原生 Rust 约 45,000): ## 测量(Wasm Lab,`apps/wasmlab`) +以下是服务改为每次调用使用全新实例之前的原始运行时测量,不代表本次加固后的 +端到端服务性能。 + 只有轻量算法:Markdown 转 HTML(pulldown-cmark)、在忙碌时段之间找空闲时段、模糊排序 (strsim)、按行差异比较(similar)。 @@ -115,10 +132,11 @@ Cranelift 与 LLVM 的代码生成,而不是向量化。Wasmtime 和 Cranelift - iOS 不允许应用使用 JIT,也不允许下载原生代码:那里只能用 Pulley(比 Cranelift 慢约 17 倍)。随系统发布的应用,其函数可以在构建应用时转成原生代码;商店应用不行。 OpenHarmony 的 JIT 策略未知(**未验证**)。 -- 限制是按调用、按实例计算的。目前还没有办法阻止一个应用用连续调用占满一个核心,或在 - 它的八个模块里各用 256 MiB;按应用的 CPU 和内存预算是后续工作。App Hub 已判定超时的 - 请求,仍会在工作线程里运行。 -- 外壳运行期间更新的应用,在外壳重启前仍使用旧函数;工作线程与外壳同寿命。 +- 客体内存不再跨调用或在八个模块间保留:每个工作线程最多有一个活动实例,最多 + 四个工作线程。持续准入的请求仍可占满这些线程;应用间 CPU 公平调度和磁盘编译缓存 + 的有界淘汰策略仍待实现。编译后的 Program 也会占用线性内存上限以外的宿主内存。 +- 运行时和外壳服务回归测试覆盖真实 Wasm 状态残留、表扩容、取消、输入/队列上限、 + 版本/权限变化及签名目录撤回。本次加固尚未在手机上重新验证。 ## 未决问题 diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index d3d55b9d..b1171628 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "b17352ebf59e50ab3db6ff1cb825c4e006bcdff47d83522e372b8fe585e57570", - "tree": "4fb9744e3d9525c725dedcb1100c14451d2bb118", + "tree": "0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -26,7 +26,8 @@ "WebReader refuses unsupported Linux/Windows platforms before queuing native browser operations instead of reporting success for an invisible view; this does not add embedded browser support.", "Opted-in camera and microphone check OS grants before prompting; background/agent requests retain nonprompt authority through callbacks and cannot use desktop timeout approval. Passive location reads remain available; legacy GPS requests require foreground prompt authority.", "Host request JSON and pre-drain queues are bounded before native allocation; delayed host callbacks retain their nonprompting origin after an agent tool completes or is cancelled.", - "Agent/background prompt denial follows detached timers, tasks, paused continuations, widget callbacks and HTTP/WebSocket callbacks, including native device helpers. A fresh foreground entry retains its own authority; scoped denial restores on exit or unwind." + "Agent/background prompt denial follows detached timers, tasks, paused continuations, widget callbacks and HTTP/WebSocket callbacks, including native device helpers. A fresh foreground entry retains its own authority; scoped denial restores on exit or unwind.", + "Native host-response envelope keys are interned when created, so contained apps can forward raw success, error and capability-refusal replies as bounded JSON without first naming each field in script source." ], "stacked": [ { @@ -100,6 +101,10 @@ { "patch": "tools/runtime-patches/makepad-async-prompt-provenance.patch", "sha256": "46bb7620ccb4f6a76a08d0333c0249d2619123da2a2649f55294fd4d35f0d905" + }, + { + "patch": "tools/runtime-patches/makepad-host-response-json.patch", + "sha256": "a95cd969b2d034ca30d38bfdb6ca6a890aaef4f24c7034076ddf72eb4c3d829a" } ] } diff --git a/tools/fixtures/wasm-phone-lab/README.md b/tools/fixtures/wasm-phone-lab/README.md new file mode 100644 index 00000000..ad730913 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.md @@ -0,0 +1,27 @@ +# Wasm phone acceptance + +English | [简体中文](README.zh-CN.md) + +This synthetic app exercises the signed Store → app tool → `host.request` → Wasm service path. It uses no personal account or model. It checks fresh state after a successful call, guest error, trap and deadline; raw native responses must survive bounded JSON conversion. It also checks account scope, undeclared tools, invalid arguments, closed endpoints and absence of an approval sheet. + +Use a clean committed checkout and an assigned Android device. The fixture embeds its source commit and runtime tree when compiled; the driver refuses stale or dirty builds. It installs only a fresh `dev.makepad.octosense.hostapilab.*` package and stops that package afterward. It does not use or replace Home. The APK is a debuggable test build, not a release or performance benchmark. + +Use your existing Android SDK, JDK, ADB and pinned Makepad packager. Set `CARGO_MAKEPAD`, `MAKEPAD_ANDROID_SDK`, `ADB`, `AAPT2`, `HUB`, `WASM_TARGET` and `WASM_EVIDENCE` to your local paths; the evidence directory must not exist. From the repository root: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +The driver stamps a temporary copy. The native host creates ephemeral signing keys in memory and uses normal signature, digest, capability and tool admission. Keys and provider credentials are not published. Receipts record APK/source/runtime identities and individual results, without device serials or SDK paths. Keep an earlier receipt separate; choose a new package suffix for another install or remove only your previous test package. + +`state.wat` is the source of `bundle/fns/state.wasm`. To reproduce the checked-in module: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +The guest deliberately remembers input if its instance is reused. Passing requires the host to create a new instance, including after success. Earlier 19-check evidence used explicit result fields and did not prove raw response forwarding; the repaired raw path adds that check, and the reproducible harness adds two compiled-identity checks. The [OnePlus 6 receipt](acceptance-oneplus6.json) records **22/22 checks passed** on Android 15, built from source `e67ce63ebca6054961899691e1644a54c2b4f081` with runtime tree `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`. The build and driver commands above were executed with local tool paths. Live model/peer relay, performance and release APK behavior remain unverified. + +The listing image is a separate native macOS capture of this fixture after its signed app tool completed, made in a hidden Makepad window from source `d3535a95`. It is not a phone screenshot and does not extend the 22-check OnePlus receipt to the later screenshot-only change. The image and refreshed fixture digest replace the earlier unrelated Host API Lab image. diff --git a/tools/fixtures/wasm-phone-lab/README.zh-CN.md b/tools/fixtures/wasm-phone-lab/README.zh-CN.md new file mode 100644 index 00000000..eab1b806 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/README.zh-CN.md @@ -0,0 +1,27 @@ +# Wasm 手机验收 + +[English](README.md) | 简体中文 + +这个合成应用验证签名商店准入 → 应用工具 → `host.request` → Wasm 服务的真实路径,不使用个人账户或模型。它检查成功调用、客体错误、陷阱和超时之后的新实例状态,以及未经重组的原生响应能否通过有大小限制的 JSON 转换。它也检查账户范围、未声明工具、无效参数、已关闭端点和审批面板未出现。 + +使用已提交且干净的检出目录和获准测试的 Android 设备。编译时把源码提交及运行时树标识嵌入测试应用,驱动拒绝过期或未提交的构建。只安装全新的 `dev.makepad.octosense.hostapilab.*` 包,并在结束后停止该包,不读取或替换 Home。这是可调试的测试 APK,不是发布包或性能基准。 + +使用已有 Android SDK、JDK、ADB 和固定版本的 Makepad 打包器。把 `CARGO_MAKEPAD`、`MAKEPAD_ANDROID_SDK`、`ADB`、`AAPT2`、`HUB`、`WASM_TARGET`、`WASM_EVIDENCE` 设置为本机路径;证据目录必须尚不存在。在仓库根目录运行: + +```sh +python3 tools/setup.py --check --cargo +MAKEPAD_FORCE_DEBUGGABLE=1 CARGO_TARGET_DIR="$WASM_TARGET" "$CARGO_MAKEPAD" makepad android --sdk-path="$MAKEPAD_ANDROID_SDK" --abi=aarch64 --version-code=2026100703 --package-name=dev.makepad.octosense.hostapilab.wasm1 --app-label=OctoSenseWasmIsolationTest build -p octosense-wasm-phone-smoke --release --locked --offline +python3 tools/test-wasm-phone.py --adb "$ADB" --aapt2 "$AAPT2" --hub "$HUB" --apk "$WASM_TARGET/makepad-android-apk/octosense_wasm_phone_smoke/apk/octo_sense_wasm_isolation_test.apk" --out "$WASM_EVIDENCE" +``` + +驱动只给临时副本重新计算摘要。原生宿主在内存中生成临时签名密钥,使用正常的签名、摘要、能力和工具准入检查,不发布密钥或服务商凭据。回执记录 APK、源码及运行时标识和各项结果,不包含设备序列号或 SDK 路径。保留旧回执;重新安装时选择新的包后缀,或只删除自己上一次的测试包。 + +`state.wat` 是 `bundle/fns/state.wasm` 的源码。重建已提交的模块: + +```sh +cargo run --locked --offline -p octosense-wasm-host --example encode_phone_fixture -- tools/fixtures/wasm-phone-lab/state.wat tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm +``` + +客体在实例复用时会主动保留输入;通过测试要求宿主在每次调用(包括成功调用)之后创建新实例。先前的 19 项结果使用了显式组装的字段,不能证明原始响应转发。修复后的原始路径增加一项转发检查,可复现的公开测试还增加两项编译标识检查。[OnePlus 6 回执](acceptance-oneplus6.json)记录 Android 15 上 **22/22 项通过**,构建源码为 `e67ce63ebca6054961899691e1644a54c2b4f081`,运行时树为 `0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc`。上面的构建和驱动命令已使用本机工具路径执行。真实模型/对等代理转发、性能和发布 APK 行为仍未验证。 + +应用列表图片是单独在 macOS 隐藏 Makepad 窗口中捕获的真实界面,源码为 `d3535a95`,拍摄时此测试应用的签名应用工具已经完成。它不是手机截图,也不把 OnePlus 的 22 项回执扩展到后续仅替换图片的提交。该图片及更新后的测试包摘要替换了先前无关的 Host API Lab 图片。 diff --git a/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json new file mode 100644 index 00000000..59b88f84 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/acceptance-oneplus6.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "platform": "android", + "model": "ONEPLUS A6003", + "android": "15", + "package": "dev.makepad.octosense.hostapilab.wasm1", + "apk_sha256": "ffdadbfbc2b268c6007856dcc7f2a8b5e971f63076738a6d6026a1abecaebc5a", + "personal_accounts_used": false, + "model_started": false, + "source_revision": "e67ce63ebca6054961899691e1644a54c2b4f081", + "runtime_tree": "0fc8e29e2411fd7eb94d845ba16a261d2ebc9dfc", + "wasm_service_sha256": "8197e7ef731d69779fc1e002aa3d4318ee227869be5aa0ee9fa2ee5da58e565f", + "checks": { + "compiled_source_identity_matches": true, + "compiled_runtime_identity_matches": true, + "signed_native_android_install": true, + "real_script_tool_completed": true, + "bound_device_account": true, + "raw_host_envelopes_forwarded": true, + "successful_guest_call": true, + "success_does_not_retain_input": true, + "guest_error_received": true, + "guest_error_does_not_retain_input": true, + "guest_trap_received": true, + "fresh_instance_after_trap": true, + "guest_deadline_received": true, + "fresh_instance_after_deadline": true, + "functions_answered": true, + "fresh_instance_policy_reported": true, + "eight_distinct_guest_invocations": true, + "no_native_approval_sheet": true, + "cross_account_refused": true, + "undeclared_tool_refused": true, + "invalid_arguments_refused": true, + "closed_tool_endpoint_refused": true + }, + "passed": true, + "not_verified": [ + "live model or peer relay", + "performance benchmark", + "release APK" + ], + "owned_package_stopped": true +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/AGENT.md b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md new file mode 100644 index 00000000..4544dbd9 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/AGENT.md @@ -0,0 +1 @@ +This is a local synthetic Wasm containment test. Never request personal data or external services. No live model is started. diff --git a/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg new file mode 100644 index 00000000..605a5588 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm b/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm new file mode 100644 index 00000000..a62efdcc Binary files /dev/null and b/tools/fixtures/wasm-phone-lab/bundle/fns/state.wasm differ diff --git a/tools/fixtures/wasm-phone-lab/bundle/listing.json b/tools/fixtures/wasm-phone-lab/bundle/listing.json new file mode 100644 index 00000000..2f36e107 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/listing.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "subtitle": "Wasm isolation acceptance fixture", + "description": "Synthetic signed fixture exercising fresh Wasm invocation state, guest errors, traps and deadlines. No accounts, model or external communication.", + "category": "utilities", + "keywords": [ + "developer", + "host-api" + ], + "screenshots": [ + "screenshots/01-main.png" + ], + "icon": "assets/icon.svg", + "platforms": [ + "android" + ], + "publisher": { + "name": "Local acceptance fixture", + "support": "https://github.com/OctoSense-org/OctoSense/issues", + "privacy_policy_url": "https://github.com/OctoSense-org/OctoSense" + }, + "release_notes": "Local validation only; ephemeral test signing keys.", + "age_rating": "all", + "license": "Apache-2.0" +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/main.splash b/tools/fixtures/wasm-phone-lab/bundle/main.splash new file mode 100644 index 00000000..a9fcdba1 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/main.splash @@ -0,0 +1,36 @@ +fn app_tool(name, call_id) { + let request = mod.app_tools.request(call_id) + let results = {} + host.request("wasm.remember", "synthetic earlier input", fn(reply_0) { + results.remember = reply_0 + host.request("wasm.recall", {}, fn(reply_1) { + results.after_success = reply_1 + host.request("wasm.remember_fail", "synthetic failed input", fn(reply_2) { + results.guest_error = reply_2 + host.request("wasm.recall", {}, fn(reply_3) { + results.after_guest_error = reply_3 + host.request("wasm.trap", {}, fn(reply_4) { + results.trap = reply_4 + host.request("wasm.recall", {}, fn(reply_5) { + results.after_trap = reply_5 + host.request("wasm.spin", {}, fn(reply_6) { + results.deadline = reply_6 + host.request("wasm.recall", {}, fn(reply_7) { + results.after_deadline = reply_7 + host.request("wasm.functions", {}, fn(reply_8) { + ui.status.set_text("Wasm checks completed") + mod.app_tools.complete(call_id, {account: request.context.account results: results functions: reply_8}) + }) + }) + }) + }) + }) + }) + }) + }) + }) +} +SolidView {width: Fill height: Fill flow: Down padding: 28 spacing: 20 draw_bg.color: #xf5f7fa + Label {text: "Wasm isolation acceptance" draw_text.color: #x17243b draw_text.text_style.font_size: 26} + status := Label {text: "Running synthetic signed app tool" draw_text.color: #x48566b} +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/manifest.json b/tools/fixtures/wasm-phone-lab/bundle/manifest.json new file mode 100644 index 00000000..821ac636 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/manifest.json @@ -0,0 +1,33 @@ +{ + "agent": { + "background": false, + "instructions": "AGENT.md", + "model": { + "local_only": false, + "needs": [ + "tool_calling" + ] + }, + "profile": "read-only", + "tools": [] + }, + "capabilities": [ + "wasm" + ], + "host_api": { + "required": { + "app_tools.dispatch": 1 + } + }, + "id": "org.octosense.samples.wasmprobe", + "integrity": { + "bundle_blake3": "162037c17ea778ddfd413c212062d07ab85d209eca75344bea361ff5f3d76fa7" + }, + "name": "Wasm isolation probe", + "requires": [ + "host-api-v1", + "script-tools-v1" + ], + "schema": 1, + "version": "0.1.0" +} diff --git a/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png b/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png new file mode 100644 index 00000000..012dd235 Binary files /dev/null and b/tools/fixtures/wasm-phone-lab/bundle/screenshots/01-main.png differ diff --git a/tools/fixtures/wasm-phone-lab/bundle/tools.json b/tools/fixtures/wasm-phone-lab/bundle/tools.json new file mode 100644 index 00000000..099dd6aa --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/bundle/tools.json @@ -0,0 +1,39 @@ +{ + "schema": 1, + "tools": [ + { + "name": "wasmprobe.inspect", + "description": "Run synthetic Wasm isolation and containment checks.", + "implemented_by": "app", + "risk": "read", + "background": false, + "shareable": false, + "private_data": true, + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "required": [ + "account", + "results", + "functions" + ], + "properties": { + "account": { + "type": "string" + }, + "results": { + "type": "object" + }, + "functions": { + "type": "object" + } + }, + "additionalProperties": false + } + } + ] +} diff --git a/tools/fixtures/wasm-phone-lab/state.wat b/tools/fixtures/wasm-phone-lab/state.wat new file mode 100644 index 00000000..ff722671 --- /dev/null +++ b/tools/fixtures/wasm-phone-lab/state.wat @@ -0,0 +1,17 @@ +(module + (memory (export "memory") 1) + (global $size (mut i32) (i32.const 0)) + (data (i32.const 8) "\01failed") + (func (export "octo_alloc") (param i32) (result i32) (i32.const 4096)) + (func (export "octo_free") (param i32 i32)) + (func $remember (param $p i32) (param $n i32) + (global.set $size (local.get $n)) + (memory.copy (i32.const 65) (local.get $p) (local.get $n))) + (func (export "remember") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 1)) + (func (export "remember_fail") (param i32 i32) (result i64) + (call $remember (local.get 0) (local.get 1)) (i64.const 34359738375)) + (func (export "recall") (param i32 i32) (result i64) + (i64.or (i64.const 274877906944) (i64.extend_i32_u (i32.add (global.get $size) (i32.const 1))))) + (func (export "trap") (param i32 i32) (result i64) unreachable) + (func (export "spin") (param i32 i32) (result i64) (loop $again (br $again)) (i64.const 1))) \ No newline at end of file diff --git a/tools/runtime-patches/makepad-host-response-json.patch b/tools/runtime-patches/makepad-host-response-json.patch new file mode 100644 index 00000000..5cb7555d --- /dev/null +++ b/tools/runtime-patches/makepad-host-response-json.patch @@ -0,0 +1,133 @@ +diff --git a/widgets/src/splash_host.rs b/widgets/src/splash_host.rs +index 257498cfc..15d9c8a76 100644 +--- a/widgets/src/splash_host.rs ++++ b/widgets/src/splash_host.rs +@@ -6,7 +6,7 @@ + //! This module is the ONE doorway for all of that: an isolate's script asks, + //! the embedding host (the launcher) decides and answers. No policy lives + //! here; an undrained or unrecognized request simply never resolves, and a +-//! host that says no answers `{ok: false}`. ++//! host that says no answers `{is_ok: false}`. + //! + //! Registered in isolates as `mod.host` (bound as `host` by the Splash + //! prefix, like `fs`): +@@ -218,10 +218,12 @@ pub fn splash_host_respond( + }; + let obj = vm.bx.heap.new_object(); + let trap = vm.bx.threads.cur().trap.pass(); ++ // Intern the native keys so the complete envelope can round-trip as JSON ++ // even when the app never names a field in its source. + // `is_ok`, never `ok`: `ok` is a script keyword and unusable as a field. +- vm.bx.heap.set_value(obj, id!(is_ok).into(), ok.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), data, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), ok.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), data, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + if !crate::splash_policy::may_run(heap_key) { + delivered = false; + return; +@@ -336,9 +338,9 @@ pub fn script_mod(vm: &mut ScriptVm) { + let obj = vm.bx.heap.new_object(); + let error = vm.bx.heap.new_string_from_str(&reason); + let trap = vm.bx.threads.cur().trap.pass(); +- vm.bx.heap.set_value(obj, id!(is_ok).into(), false.into(), trap); +- vm.bx.heap.set_value(obj, id!(data).into(), NIL, trap); +- vm.bx.heap.set_value(obj, id!(error).into(), error, trap); ++ vm.bx.heap.set_value(obj, id_lut!(is_ok).into(), false.into(), trap); ++ vm.bx.heap.set_value(obj, id_lut!(data).into(), NIL, trap); ++ vm.bx.heap.set_value(obj, id_lut!(error).into(), error, trap); + vm.call(callback.as_object().into(), &[obj.into()]); + (0.0f64).into() + } +@@ -413,6 +415,90 @@ pub fn script_mod(vm: &mut ScriptVm) { + mod tests { + use super::*; + ++ #[test] ++ fn raw_service_envelopes_round_trip_without_script_field_lookups() { ++ const CHILD: &str = "MAKEPAD_TEST_RAW_HOST_ENVELOPE"; ++ if std::env::var_os(CHILD).is_none() { ++ for mode in ["success", "error", "refusal"] { ++ let output = std::process::Command::new(std::env::current_exe().unwrap()) ++ .args(["--exact", "splash_host::tests::raw_service_envelopes_round_trip_without_script_field_lookups", "--nocapture"]) ++ .env(CHILD, mode).output().unwrap(); ++ assert!( ++ output.status.success(), ++ "{mode}: {}\n{}", ++ String::from_utf8_lossy(&output.stdout), ++ String::from_utf8_lossy(&output.stderr) ++ ); ++ } ++ return; ++ } ++ use crate::*; ++ let mode = std::env::var(CHILD).unwrap(); ++ let mut cx = Cx::new(Box::new(|_, _| {})); ++ let root = cx.with_vm(|vm| { ++ crate::script_mod(vm); ++ let value = script_eval!(vm, { use mod.widgets.* View { card := Splash{} } }); ++ WidgetRef::script_from_value(vm, value) ++ }); ++ assert!( ++ [id!(is_ok), id!(data), id!(error)] ++ .iter() ++ .any(|id| id.as_string(|text| text.is_none())), ++ "fresh-process control: the script must not have interned every native envelope key" ++ ); ++ let card = root.splash(&mut cx, ids!(card)); ++ card.set_host_caps(&mut cx, vec!["fixture".into()]); ++ card.set_policy(&mut cx, Some(vec![]), Some(100_000)); ++ let service = if mode == "refusal" { ++ "ungranted.read" ++ } else { ++ "fixture.read" ++ }; ++ // In particular, do not reference any envelope field in this source: ++ // field access would intern its spelling and mask the native bug. ++ card.set_text( ++ &mut cx, ++ &format!( ++ r#" ++ host.request("{service}", {{}}, fn(reply) {{ ++ host.request("fixture.forward", reply, nil) ++ }}) ++ Label{{text:"raw response fixture"}} ++ "# ++ ), ++ ); ++ let heap = card.isolate_heap_key(&mut cx).unwrap(); ++ if mode != "refusal" { ++ let initial = take_splash_host_requests_for(&[heap]).pop().unwrap(); ++ let result = if mode == "success" { ++ Ok(r#"{"unknown_payload_key": [-2, "value"]}"#) ++ } else { ++ Err("synthetic failure") ++ }; ++ splash_host_respond(&mut cx, heap, initial.req_id, result); ++ } ++ let forwarded = take_splash_host_requests_for(&[heap]); ++ assert_eq!( ++ forwarded.len(), ++ 1, ++ "a raw envelope remains bounded, serializable JSON" ++ ); ++ assert_eq!(forwarded[0].service, "fixture.forward"); ++ let value: serde_json::Value = serde_json::from_str(&forwarded[0].args_json).unwrap(); ++ assert_eq!(value["is_ok"], mode == "success"); ++ if mode == "success" { ++ assert_eq!( ++ value["data"]["unknown_payload_key"], ++ serde_json::json!([-2, "value"]) ++ ); ++ assert!(value["error"].is_null()); ++ } else { ++ assert!(value["data"].is_null()); ++ assert!(value["error"].as_str().is_some_and(|text| !text.is_empty())); ++ } ++ gc_bridge(&[heap]); ++ } ++ + #[test] + fn delayed_background_callbacks_keep_nested_requests_nonprompting() { + use crate::*; diff --git a/tools/test-wasm-phone.py b/tools/test-wasm-phone.py new file mode 100644 index 00000000..41ec4dfb --- /dev/null +++ b/tools/test-wasm-phone.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Run the signed Host API Lab on an assigned Android phone in a fresh package. + +Synthetic Wasm input only. No account, model, permission grant or production Home change. +The native host uses the same app-tool/host-service code as the desktop lab. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + for key in ('adb', 'apk', 'aapt2', 'hub', 'out'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--serial', help='Assigned phone only; omitted from evidence') + parser.add_argument('--package', default='dev.makepad.octosense.hostapilab.wasm1') + args = parser.parse_args() + if not re.fullmatch(r'dev\.makepad\.octosense\.hostapilab(?:\.[a-z0-9_]+)*', args.package): + parser.error('Use a separate hostapilab package, never Home') + source_revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + if subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT): + parser.error('Build and run the fixture from a clean committed source tree') + runtime_tree = json.loads((ROOT / 'runtime-patches.lock.json').read_text())['makepad']['tree'] + args.out.mkdir(mode=0o700, parents=True, exist_ok=False) + base = [str(args.adb)] + (['-s', args.serial] if args.serial else []) + + def adb(*values, data=None, checked=True): + return subprocess.run(base + list(values), input=data, check=checked, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=45) + + devices = adb('devices').stdout.decode().splitlines()[1:] + if not args.serial and sum(line.endswith('\tdevice') for line in devices) != 1: + parser.error('Select the assigned phone with --serial') + model = adb('shell', 'getprop', 'ro.product.model').stdout.decode().strip() + android = adb('shell', 'getprop', 'ro.build.version.release').stdout.decode().strip() + present = adb('shell', 'pm', 'path', args.package, checked=False) + if present.returncode not in (0, 1) or present.stderr.strip() or present.stdout.strip(): + parser.error('Expected a fresh, absent package; select a new suffix') + badging = subprocess.run([str(args.aapt2), 'dump', 'badging', str(args.apk)], + check=True, capture_output=True).stdout.decode() + if f"name='{args.package}'" not in badging or 'application-debuggable' not in badging: + parser.error('APK must match the separate debuggable package') + bundle = args.out / 'bundle' + shutil.copytree(ROOT / 'tools/fixtures/wasm-phone-lab/bundle', bundle) + listing = json.loads((bundle / 'listing.json').read_text()) + listing['platforms'] = ['android'] + (bundle / 'listing.json').write_text(json.dumps(listing, indent=2) + '\n') + subprocess.run([str(args.hub), 'stamp', str(bundle)], check=True, capture_output=True) + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode='w') as tar: + for path in sorted(bundle.rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + raise ValueError('Fixture accepts regular files and directories only') + tar.add(path, arcname='wasm-phone-lab/bundle/' + str(path.relative_to(bundle)), recursive=False) + installed = False + receipt = {'schema': 1, 'platform': 'android', 'model': model, 'android': android, + 'package': args.package, 'apk_sha256': hashlib.sha256(args.apk.read_bytes()).hexdigest(), + 'personal_accounts_used': False, 'model_started': False, 'source_revision': source_revision, 'runtime_tree': runtime_tree, 'wasm_service_sha256': hashlib.sha256((ROOT/'crates/shell/src/wasm_service.rs').read_bytes()).hexdigest(), 'checks': {}} + try: + adb('install', str(args.apk)) + installed = True + adb('shell', 'run-as', args.package, 'mkdir', '-p', 'files') + adb('shell', '-T', 'run-as', args.package, 'tar', '-xf', '-', '-C', 'files', data=archive.getvalue()) + adb('shell', 'am', 'start', '-n', args.package + '/.MakepadApp') + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + result = adb('exec-out', 'run-as', args.package, 'cat', + 'files/wasm-phone-lab/receipt.json', checked=False) + if result.returncode == 0: + try: + native = json.loads(result.stdout) + break + except ValueError: + pass + time.sleep(.25) + else: + raise AssertionError('Native phone host did not produce a receipt') + (args.out / 'native-result.json').write_text(json.dumps(native, indent=2) + '\n') + checks = receipt['checks'] + + def require(name, condition): + checks[name] = bool(condition) + if not condition: + raise AssertionError(name) + + require('compiled_source_identity_matches', native['source_revision'] == source_revision and native['source_dirty'] is False) + require('compiled_runtime_identity_matches', native['runtime_tree'] == runtime_tree) + require('signed_native_android_install', native['signed_install'] is True and native['platform'] == 'android') + require('real_script_tool_completed', 'Ok' in native['tool_result']) + data = native['tool_result']['Ok'] + require('bound_device_account', data['account'] == 'device') + raw_results = data['results'] + require('raw_host_envelopes_forwarded', all(set(r) == {'is_ok','data','error'} for r in raw_results.values()) and set(data['functions']) == {'is_ok','data','error'}) + results = {name: {'is_ok': r['is_ok'], 'text': r['data']['text'] if r['is_ok'] else '', 'error': r['error']} for name, r in raw_results.items()} + require('successful_guest_call', results['remember']['is_ok'] is True) + require('success_does_not_retain_input', results['after_success']['is_ok'] is True and results['after_success']['text'] == '') + require('guest_error_received', results['guest_error']['is_ok'] is False and 'failed' in results['guest_error']['error']) + require('guest_error_does_not_retain_input', results['after_guest_error']['is_ok'] is True and results['after_guest_error']['text'] == '') + require('guest_trap_received', results['trap']['is_ok'] is False and 'unreachable' in results['trap']['error'].lower()) + require('fresh_instance_after_trap', results['after_trap']['is_ok'] is True and results['after_trap']['text'] == '') + require('guest_deadline_received', results['deadline']['is_ok'] is False and 'deadline' in results['deadline']['error'].lower()) + require('fresh_instance_after_deadline', results['after_deadline']['is_ok'] is True and results['after_deadline']['text'] == '') + require('functions_answered', data['functions']['is_ok'] is True) + modules = data['functions']['data']['modules'] + require('fresh_instance_policy_reported', len(modules) == 1 and modules[0]['instance_policy'] == 'fresh-per-call') + require('eight_distinct_guest_invocations', modules[0]['invocations'] == 8) + require('no_native_approval_sheet', native['host_sheet_visible'] is False) + require('cross_account_refused', 'account_scope' in native['refusals']['wrong_account']) + require('undeclared_tool_refused', bool(native['refusals']['undeclared_tool'])) + require('invalid_arguments_refused', bool(native['refusals']['invalid_arguments'])) + require('closed_tool_endpoint_refused', bool(native['closed_app'])) + receipt['passed'] = True + receipt['not_verified'] = native['not_verified'] + except Exception as error: + receipt['passed'] = False + # Subprocess exceptions include their command (possibly --serial and + # private build paths). Only our fixed assertion messages are public. + receipt['error'] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + raise + finally: + if installed: + try: + receipt['owned_package_stopped'] = adb( + 'shell', 'am', 'force-stop', args.package, checked=False).returncode == 0 + except subprocess.SubprocessError: + receipt['owned_package_stopped'] = False + (args.out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps({'passed': receipt['passed'], 'checks': len(receipt['checks']), + 'owned_package_stopped': receipt['owned_package_stopped']})) + + +if __name__ == '__main__': + main()