From 90f5fa87fcecb41b5de5f2121c78ba0bf4ecc3b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:51:28 +0000 Subject: [PATCH 1/7] wasi: the task's named grants as preopens the embedder offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest's filesystem was one preopen, `.`, the task's root view, walked one containment step at a time — deliberately never through the task's named grants, since a guest opening `data/x` would reach authority its embedder holds and never handed it. That left a guest unable to see what its launcher sees: on kore the app store seeds programs and their data under `shared`, a grant the shell's own walk resolves by name, and CPython launched as `shared/wasm/python.wasm` could stat neither itself nor `shared/wasm/lib/python312.zip` beside it (`getpath` then fell back to a prefix of `/` and died for want of `encodings`). `Filesystem::with_named_grants` lets the embedder offer them: each named grant becomes a preopen of its own under its store name beside `.`, opened with the widest rights the service answers (the root's own ladder), the private root and unnamed grants left out. The guest walks beneath a grant exactly as beneath `.` and still never reaches one *through* `.`; an embedder that says nothing offers nothing, as before. The listing is read whole before any grant is opened, since the next request reuses the pair's window. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 92 ++++++++++++++++++++++++++++-- crates/wasi/src/filesystem/mod.rs | 39 +++++++++++-- crates/wasi/src/filesystem/walk.rs | 4 ++ 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index 97feb31..b6c3264 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -31,6 +31,7 @@ use core::future::poll_fn; use core::pin::Pin; use core::task::{Context, Poll}; +use kore::fs::dag::GrantId; use kore::fs::{Fs, FsError, Name, Refusal, Rights, View}; use kore::runtime::Pinned; @@ -208,6 +209,9 @@ pub(crate) struct FsHost { /// The largest transfer, fixed at attach: what one stream write may /// carry. chunk: usize, + /// Whether the task's named grants are offered as preopens of their + /// names beside `.` ([`Filesystem::with_named_grants`]). + grants: Cell, } impl FsHost { @@ -215,6 +219,10 @@ impl FsHost { self.chunk } + pub(crate) fn offers_grants(&self) -> bool { + self.grants.get() + } + /// Drive the op in the slot; `Ready` once the slot is empty. fn poll_slot(&self, cx: &mut Context<'_>) -> Poll<()> { let Ok(mut slot) = self.slot.try_borrow_mut() else { @@ -305,7 +313,11 @@ impl FsHost { /// A task's filesystem, given to a guest as one preopen, `.`: the task's /// own root view (on Kore, the directory it was launched in). The guest -/// resolves paths only beneath it — never through the task's named grants. +/// resolves paths only beneath it — never through the task's named grants +/// from there. An embedder that wants the guest to see those grants names +/// them ([`with_named_grants`](Self::with_named_grants)): each is then a +/// preopen of its own, under the name the task's store gives it, and the +/// guest resolves beneath it the same way. /// /// `Send`, as a store's data must be; the pair is thread-bound and usable /// only on the thread that pinned it. @@ -325,10 +337,27 @@ impl Filesystem { }), slot: RefCell::new(None), chunk, + grants: Cell::new(false), }); Pinned::new(host).ok().map(Self) } + /// Offer the task's named grants to the guest too, each as a preopen + /// under its store name beside `.`: a program launched as + /// `shared/wasm/python.wasm` then finds `shared/wasm/lib/` where its + /// launcher does, since `shared` is a grant the launcher's own walk + /// resolves and the guest's one-step walk from `.` cannot. The + /// embedder decides: a grant it holds for itself — a private data + /// grant — is never offered unless it says so, and the grants offered + /// are the task's, named as the task names them, with the rights the + /// service answers for each. + pub fn with_named_grants(self) -> Self { + if let Some(host) = self.0.get() { + host.grants.set(true); + } + self + } + pub(crate) fn host(&self) -> Option<&Rc> { self.0.get() } @@ -348,13 +377,68 @@ impl Filesystem { } } +/// Widest first: the rights a preopen may carry, probed in order until +/// the service answers one — the task root's, and each named grant's. +const PREOPEN_RIGHTS: [Rights; 4] = { + const MUTATE: Rights = Rights::READ.union(Rights::MUTATE_NAMESPACE); + const WRITE: Rights = Rights::READ.union(Rights::WRITE); + [MUTATE.union(Rights::WRITE), MUTATE, WRITE, Rights::READ] +}; + +/// The task's named grants, each opened with the widest rights the service +/// answers for it, in the order the store lists them: what +/// [`Filesystem::with_named_grants`] offers beside the root. The private +/// root is not among them (it is `.`), nor is a grant without a name (a +/// guest could not spell it). A grant the service refuses outright is left +/// out; one it refuses only at a width is tried at the next. +pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, Shared, Rights)> { + host.exec(async |pair: &mut Custody| { + // The listing lands in the pair's window, which the next request + // reuses: read every entry before opening any. + let named: Vec<(Name, GrantId)> = pair + .call(async |fs: &mut Face| { + let count = fs.shares().await?; + let mut named = Vec::new(); + for index in 0..count as usize { + let entry = fs.share_entry(index)?; + if entry.is_private_root { + continue; + } + if let Some(name) = entry.display_name { + named.push((name, entry.grant)); + } + } + Ok(named) + }) + .await + .unwrap_or_default(); + let mut preopens = Vec::with_capacity(named.len()); + for (name, grant) in named { + for rights in PREOPEN_RIGHTS { + let root = pair + .call(async move |fs: &mut Face| fs.open_grant(grant, rights).await) + .await; + match root { + Ok(root) => { + preopens.push((name, pair.own(root), rights)); + break; + } + Err(Refusal::Fs(FsError::AccessDenied)) => {} + Err(_) => break, + } + } + } + preopens + }) + .await + .unwrap_or_default() +} + /// The task root with the widest of the rights a preopen may carry; each /// probe is the service's own answer. pub(crate) async fn preopen(host: &FsHost) -> Option<(Shared, Rights)> { - const MUTATE: Rights = Rights::READ.union(Rights::MUTATE_NAMESPACE); - const WRITE: Rights = Rights::READ.union(Rights::WRITE); host.exec(async |pair: &mut Custody| { - for rights in [MUTATE.union(Rights::WRITE), MUTATE, WRITE, Rights::READ] { + for rights in PREOPEN_RIGHTS { let root = pair .call(async move |fs: &mut Face| fs.open_root(rights).await) .await; diff --git a/crates/wasi/src/filesystem/mod.rs b/crates/wasi/src/filesystem/mod.rs index cb0b513..04f1b64 100644 --- a/crates/wasi/src/filesystem/mod.rs +++ b/crates/wasi/src/filesystem/mod.rs @@ -1020,8 +1020,10 @@ impl types::HostDirectoryEntryStream for WasiCtxView<'_> { } impl preopens::Host for WasiCtxView<'_> { - /// `.` — the task's root view — or nothing, for a task without a - /// filesystem or whose root could not be opened. + /// `.` — the task's root view — then, when the embedder offers them, + /// the task's named grants under their names + /// ([`Filesystem::with_named_grants`]); or nothing, for a task without + /// a filesystem or whose root could not be opened. async fn get_directories(&mut self) -> wasmtime::Result, String)>> { let Some(filesystem) = self.ctx.filesystem.as_ref() else { return Ok(Vec::new()); @@ -1034,9 +1036,34 @@ impl preopens::Host for WasiCtxView<'_> { Ok(preopen) => preopen, Err(_) => return Ok(Vec::new()), }; - Ok(match preopen.await { - Some(root) => vec![(self.table.push(root)?, String::from("."))], - None => Vec::new(), - }) + let Some(root) = preopen.await else { + return Ok(Vec::new()); + }; + let mut directories = vec![(self.table.push(root)?, String::from("."))]; + let offers_grants = filesystem.host().is_some_and(|host| host.offers_grants()); + if !offers_grants { + return Ok(directories); + } + // Bound by a `match`, not awaited in one: the scrutinee's `Err` side + // is the unpinned future, which must not live across the await. + let grants = match Pinned::new(async move { + let host = filesystem.host()?; + let mut grants = Vec::new(); + for (name, view, rights) in host::preopen_grants(host).await { + if let Some(descriptor) = + Descriptor::new(host.clone(), view, Vec::new(), preopen_flags(rights)) + { + grants.push((name, descriptor)); + } + } + Some(grants) + }) { + Ok(grants) => grants, + Err(_) => return Ok(directories), + }; + for (name, descriptor) in grants.await.unwrap_or_default() { + directories.push((self.table.push(descriptor)?, String::from(name.as_str()))); + } + Ok(directories) } } diff --git a/crates/wasi/src/filesystem/walk.rs b/crates/wasi/src/filesystem/walk.rs index 5ede6e5..83f23c4 100644 --- a/crates/wasi/src/filesystem/walk.rs +++ b/crates/wasi/src/filesystem/walk.rs @@ -17,6 +17,10 @@ //! the task root regardless of the descriptor: both are refused before any //! request ([`components`]). A descriptor cannot reach above its preopen. //! +//! A named grant the embedder offers (`Filesystem::with_named_grants`) is a +//! preopen of its own, under the grant's name: the guest walks beneath it +//! exactly as beneath `.`, and still never reaches a grant *through* `.`. +//! //! [`Fs::open`]: kore::fs::Fs::open //! [`Fs::open_at`]: kore::fs::Fs::open_at //! [`Fs::open_root`]: kore::fs::Fs::open_root From 7010ac6507aa3af7a4a3417cee0d4a3f41d652e0 Mon Sep 17 00:00:00 2001 From: Cass Date: Wed, 14 Oct 2026 11:25:09 +0000 Subject: [PATCH 2/7] wasi: a descriptor reopens from its own preopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `open-at` of `.` reopens a directory by its path from the preopen, since a view has no duplicate verb — and the preopen was always the task root. With named grants offered as preopens of their own, a descriptor beneath one reopened as `.` would have walked its path from the root instead and named a different object, or none. A descriptor now carries its origin, the root or the grant it descends from, and reopens from there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 4 +-- crates/wasi/src/filesystem/mod.rs | 44 +++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index b6c3264..eca5e3c 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -391,7 +391,7 @@ const PREOPEN_RIGHTS: [Rights; 4] = { /// root is not among them (it is `.`), nor is a grant without a name (a /// guest could not spell it). A grant the service refuses outright is left /// out; one it refuses only at a width is tried at the next. -pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, Shared, Rights)> { +pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, GrantId, Shared, Rights)> { host.exec(async |pair: &mut Custody| { // The listing lands in the pair's window, which the next request // reuses: read every entry before opening any. @@ -420,7 +420,7 @@ pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, Shared, Rights)> .await; match root { Ok(root) => { - preopens.push((name, pair.own(root), rights)); + preopens.push((name, grant, pair.own(root), rights)); break; } Err(Refusal::Fs(FsError::AccessDenied)) => {} diff --git a/crates/wasi/src/filesystem/mod.rs b/crates/wasi/src/filesystem/mod.rs index 04f1b64..7793f2c 100644 --- a/crates/wasi/src/filesystem/mod.rs +++ b/crates/wasi/src/filesystem/mod.rs @@ -42,7 +42,7 @@ use alloc::vec::Vec; use core::fmt; use kore::fs::dag::bounds::MAX_DIR_ENTRIES; -use kore::fs::dag::{ObjectKind, ProviderError}; +use kore::fs::dag::{GrantId, ObjectKind, ProviderError}; use kore::fs::{DirEntry, FsError as Refused, Name, Refusal, Rights, Stat, View}; use kore::runtime::Pinned; use wasmtime::component::{Resource, ResourceTableError}; @@ -204,13 +204,27 @@ fn preopen_flags(rights: Rights) -> DescriptorFlags { } /// An open file or directory: a Kore view the guest holds, with the path -/// it was opened by (from the preopen) and the flags it was opened with. +/// it was opened by (from its preopen) and the flags it was opened with. pub struct Descriptor(Pinned); +/// The preopen a descriptor descends from: what its path is relative to, +/// and what reopening it by that path starts from. A view has no +/// duplicate verb, so `open-at` of `.` reopens the directory from here. +#[derive(Clone, Copy)] +enum Origin { + /// `.`, the task's root view. + Root, + /// One of the task's named grants the embedder offered + /// ([`Filesystem::with_named_grants`]). + Grant(GrantId), +} + struct Open { /// Shared with the streams and listings made from this descriptor. file: Shared, host: Rc, + origin: Origin, + /// From `origin`; empty for the preopen itself. path: Vec, flags: DescriptorFlags, } @@ -219,12 +233,14 @@ impl Descriptor { fn new( host: Rc, file: Shared, + origin: Origin, path: Vec, flags: DescriptorFlags, ) -> Option { let open = Open { file, host, + origin, path, flags, }; @@ -395,6 +411,7 @@ impl Open { } else { rights }; + let origin = self.origin; let from_preopen = self.path.clone(); let mut path_of = self.path.clone(); path_of.extend_from_slice(&names); @@ -406,13 +423,16 @@ impl Open { // awaited must not leave it a bare view. let file = match names.split_last() { // `.`: this directory again, reopened by its own path - // from the preopen (a view has no duplicate verb). + // from its preopen (a view has no duplicate verb). None => { if create && exclusive { return Err(Refusal::Fs(Refused::AlreadyExists)); } let root = pair - .call(async |fs: &mut Face| fs.open_root(rights).await) + .call(async |fs: &mut Face| match origin { + Origin::Root => fs.open_root(rights).await, + Origin::Grant(grant) => fs.open_grant(grant, rights).await, + }) .await?; let root = pair.own(root); if from_preopen.is_empty() { @@ -463,7 +483,7 @@ impl Open { Ok(file) }) .await?; - Descriptor::new(self.host.clone(), file, path_of, flags).ok_or_else(off_thread) + Descriptor::new(self.host.clone(), file, origin, path_of, flags).ok_or_else(off_thread) } /// The directory holding `path`'s last component, and that component. @@ -1031,7 +1051,7 @@ impl preopens::Host for WasiCtxView<'_> { let preopen = match Pinned::new(async move { let host = filesystem.host()?; let (root, rights) = host::preopen(host).await?; - Descriptor::new(host.clone(), root, Vec::new(), preopen_flags(rights)) + Descriptor::new(host.clone(), root, Origin::Root, Vec::new(), preopen_flags(rights)) }) { Ok(preopen) => preopen, Err(_) => return Ok(Vec::new()), @@ -1049,10 +1069,14 @@ impl preopens::Host for WasiCtxView<'_> { let grants = match Pinned::new(async move { let host = filesystem.host()?; let mut grants = Vec::new(); - for (name, view, rights) in host::preopen_grants(host).await { - if let Some(descriptor) = - Descriptor::new(host.clone(), view, Vec::new(), preopen_flags(rights)) - { + for (name, grant, view, rights) in host::preopen_grants(host).await { + if let Some(descriptor) = Descriptor::new( + host.clone(), + view, + Origin::Grant(grant), + Vec::new(), + preopen_flags(rights), + ) { grants.push((name, descriptor)); } } From acad20084c5e922328b0a42f8f1317636e7df318 Mon Sep 17 00:00:00 2001 From: Cass Date: Wed, 14 Oct 2026 11:40:29 +0000 Subject: [PATCH 3/7] wasi: a refused preopen traps the guest with the refusal `get-directories` answered an empty list when the task's root could not be opened, and left out a named grant the service refused, so a guest read "no files" where the service had said why. The interface has no error to return; the refusal is now a trap naming what was refused. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 45 +++++++++++++++++++-------- crates/wasi/src/filesystem/mod.rs | 49 ++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index eca5e3c..b37b6a9 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -391,7 +391,9 @@ const PREOPEN_RIGHTS: [Rights; 4] = { /// root is not among them (it is `.`), nor is a grant without a name (a /// guest could not spell it). A grant the service refuses outright is left /// out; one it refuses only at a width is tried at the next. -pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, GrantId, Shared, Rights)> { +pub(crate) async fn preopen_grants( + host: &FsHost, +) -> Result, GrantsRefused> { host.exec(async |pair: &mut Custody| { // The listing lands in the pair's window, which the next request // reuses: read every entry before opening any. @@ -411,9 +413,10 @@ pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, GrantId, Shared, Ok(named) }) .await - .unwrap_or_default(); + .map_err(GrantsRefused::Listing)?; let mut preopens = Vec::with_capacity(named.len()); for (name, grant) in named { + let mut refused = Refusal::Pair; for rights in PREOPEN_RIGHTS { let root = pair .call(async move |fs: &mut Face| fs.open_grant(grant, rights).await) @@ -421,35 +424,51 @@ pub(crate) async fn preopen_grants(host: &FsHost) -> Vec<(Name, GrantId, Shared, match root { Ok(root) => { preopens.push((name, grant, pair.own(root), rights)); + refused = Refusal::Fs(FsError::AccessDenied); break; } - Err(Refusal::Fs(FsError::AccessDenied)) => {} - Err(_) => break, + Err(error @ Refusal::Fs(FsError::AccessDenied)) => refused = error, + Err(error) => return Err(GrantsRefused::Grant(name, error)), } } + if preopens.last().is_none_or(|(last, ..)| *last != name) { + return Err(GrantsRefused::Grant(name, refused)); + } } - preopens + Ok(preopens) }) .await - .unwrap_or_default() + .unwrap_or(Err(GrantsRefused::Listing(Refusal::Pair))) +} + +/// Why the task's named grants could not be offered. +pub(crate) enum GrantsRefused { + /// The listing itself was refused (`Pair` when the pair is gone). + Listing(Refusal), + /// This grant could not be opened at any width: the service's refusal + /// of the narrowest probe, or the first for a reason other than width. + Grant(Name, Refusal), } /// The task root with the widest of the rights a preopen may carry; each -/// probe is the service's own answer. -pub(crate) async fn preopen(host: &FsHost) -> Option<(Shared, Rights)> { +/// probe is the service's own answer. `Err` is the service's refusal of +/// the narrowest probe, or of the first one it refused for a reason other +/// than width; `Pair` when the pair is gone. +pub(crate) async fn preopen(host: &FsHost) -> Result<(Shared, Rights), Refusal> { host.exec(async |pair: &mut Custody| { + let mut refused = Refusal::Pair; for rights in PREOPEN_RIGHTS { let root = pair .call(async move |fs: &mut Face| fs.open_root(rights).await) .await; match root { - Ok(root) => return Some((pair.own(root), rights)), - Err(Refusal::Fs(FsError::AccessDenied)) => {} - Err(_) => return None, + Ok(root) => return Ok((pair.own(root), rights)), + Err(error @ Refusal::Fs(FsError::AccessDenied)) => refused = error, + Err(error) => return Err(error), } } - None + Err(refused) }) .await - .flatten() + .unwrap_or(Err(Refusal::Pair)) } diff --git a/crates/wasi/src/filesystem/mod.rs b/crates/wasi/src/filesystem/mod.rs index 7793f2c..b1d8325 100644 --- a/crates/wasi/src/filesystem/mod.rs +++ b/crates/wasi/src/filesystem/mod.rs @@ -80,6 +80,15 @@ impl FsError { Failure::Trap(_) => None, } } + + /// As a trap: an error code where the interface has no error to answer + /// with becomes one naming the code. + fn into_trap(self) -> wasmtime::Error { + match self.0 { + Failure::Code(code) => wasmtime::format_err!("{code:?}"), + Failure::Trap(error) => error, + } + } } impl From for FsError { @@ -1049,16 +1058,19 @@ impl preopens::Host for WasiCtxView<'_> { return Ok(Vec::new()); }; let preopen = match Pinned::new(async move { - let host = filesystem.host()?; - let (root, rights) = host::preopen(host).await?; + let host = filesystem.host().ok_or_else(off_thread)?; + let (root, rights) = host::preopen(host).await.map_err(|refusal| { + FsError::trap(wasmtime::format_err!( + "the task's root could not be opened for the guest: {refusal:?}" + )) + })?; Descriptor::new(host.clone(), root, Origin::Root, Vec::new(), preopen_flags(rights)) + .ok_or_else(off_thread) }) { Ok(preopen) => preopen, Err(_) => return Ok(Vec::new()), }; - let Some(root) = preopen.await else { - return Ok(Vec::new()); - }; + let root = preopen.await.map_err(FsError::into_trap)?; let mut directories = vec![(self.table.push(root)?, String::from("."))]; let offers_grants = filesystem.host().is_some_and(|host| host.offers_grants()); if !offers_grants { @@ -1067,25 +1079,36 @@ impl preopens::Host for WasiCtxView<'_> { // Bound by a `match`, not awaited in one: the scrutinee's `Err` side // is the unpinned future, which must not live across the await. let grants = match Pinned::new(async move { - let host = filesystem.host()?; + let host = filesystem.host().ok_or_else(off_thread)?; let mut grants = Vec::new(); - for (name, grant, view, rights) in host::preopen_grants(host).await { - if let Some(descriptor) = Descriptor::new( + let offered = host::preopen_grants(host).await.map_err(|refused| { + FsError::trap(match refused { + host::GrantsRefused::Listing(refusal) => wasmtime::format_err!( + "the task's named grants could not be listed for the guest: {refusal:?}" + ), + host::GrantsRefused::Grant(name, refusal) => wasmtime::format_err!( + "the task's grant `{}` could not be opened for the guest: {refusal:?}", + name.as_str() + ), + }) + })?; + for (name, grant, view, rights) in offered { + let descriptor = Descriptor::new( host.clone(), view, Origin::Grant(grant), Vec::new(), preopen_flags(rights), - ) { - grants.push((name, descriptor)); - } + ) + .ok_or_else(off_thread)?; + grants.push((name, descriptor)); } - Some(grants) + Ok::<_, FsError>(grants) }) { Ok(grants) => grants, Err(_) => return Ok(directories), }; - for (name, descriptor) in grants.await.unwrap_or_default() { + for (name, descriptor) in grants.await.map_err(FsError::into_trap)? { directories.push((self.table.push(descriptor)?, String::from(name.as_str()))); } Ok(directories) From d2fcddb1d27dcc81a1e1c318b5d39ac488cb2ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:25:18 +0000 Subject: [PATCH 4/7] wasi: hand back a completed preopen op's result, and open each preopen once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults kept a guest from ever seeing its preopens on Kore, where a filesystem call parks and is driven to completion across turns. `FsHost::exec` drives the task's one filesystem op through a single slot: it installs the op, polls it to `Ready`, and the op writes its result into a shared cell. The loop's final arm — reached once the op has been taken and the slot has drained — returned `None` unconditionally, throwing away the result the op had just written whenever the op queued no view to close (the common case: `get-directories`' opens return their views owned, so nothing is orphaned). The one caller reads that `None` as a lost pair, so every `get-directories` answered an empty preopen list and a program that looks beside its executable — CPython for its stdlib — found nothing. The arm now hands back what the op produced. And `get-directories` reopened the root and every named grant on each call. The preview1 adapter asks once at startup and again for each preopen name it reads back, so a handful of fds became dozens of opens and immediate closes. The preopens are now opened once, on the first call, and every later call answers descriptors over the same shared views — one open per preopen for the guest's life, one route held per grant, released with the filesystem. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 53 ++++++++++++++++- crates/wasi/src/filesystem/mod.rs | 93 ++++++++++++++++-------------- 2 files changed, 101 insertions(+), 45 deletions(-) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index b37b6a9..90e7ea7 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -212,6 +212,12 @@ pub(crate) struct FsHost { /// Whether the task's named grants are offered as preopens of their /// names beside `.` ([`Filesystem::with_named_grants`]). grants: Cell, + /// The preopens once opened ([`FsHost::preopens`]). A guest asks for + /// them more than once — the preview1 adapter once at start and once + /// per name it reads back — and every answer shares the views the + /// first opened, so a guest's start costs the service one open per + /// preopen and holds one route per grant. + preopens: RefCell>>, } impl FsHost { @@ -223,6 +229,26 @@ impl FsHost { self.grants.get() } + /// The guest's preopens: the task root as `.`, then the named grants + /// when the embedder offers them — opened on the first call, shared by + /// every later one. A refusal is answered each time it is asked. + pub(crate) async fn preopens(&self) -> Result, PreopensRefused> { + if let Some(preopens) = self.preopens.borrow().clone() { + return Ok(preopens); + } + let root = preopen(self).await.map_err(PreopensRefused::Root)?; + let grants = if self.offers_grants() { + preopen_grants(self) + .await + .map_err(PreopensRefused::Grants)? + } else { + Vec::new() + }; + let preopens = Rc::new(Preopens { root, grants }); + *self.preopens.borrow_mut() = Some(preopens.clone()); + Ok(preopens) + } + /// Drive the op in the slot; `Ready` once the slot is empty. fn poll_slot(&self, cx: &mut Context<'_>) -> Poll<()> { let Ok(mut slot) = self.slot.try_borrow_mut() else { @@ -299,7 +325,15 @@ impl FsHost { continue; } let Some(op) = op.take() else { - return Poll::Ready(None); + // The op was installed on an earlier turn and has now + // finished — the slot is empty here only because its poll + // returned `Ready`, which is also what set `out`. When the + // op queued no view to close (the common case: it returns + // the views it opened, owned), the orphan arm above does + // not fire, so this is where its result is handed back. + // Returning `None` here instead would drop it, and the one + // caller reads that as a lost pair. + return Poll::Ready(out.take()); }; let (cell, out) = (self.cell.clone(), out.clone()); self.install(Box::pin(async move { @@ -338,6 +372,7 @@ impl Filesystem { slot: RefCell::new(None), chunk, grants: Cell::new(false), + preopens: RefCell::new(None), }); Pinned::new(host).ok().map(Self) } @@ -371,6 +406,8 @@ impl Filesystem { /// left to the service's teardown of this session. pub async fn close(self) -> Option> { let host = self.0.into_inner().ok()?; + // The preopens' views go first: queued now, closed by the settle. + drop(host.preopens.take()); host.settle().await; let host = Rc::into_inner(host)?; Rc::into_inner(host.cell)?.fs.into_inner() @@ -441,6 +478,20 @@ pub(crate) async fn preopen_grants( .unwrap_or(Err(GrantsRefused::Listing(Refusal::Pair))) } +/// What a guest is given as preopens: the task root, and the named grants +/// the embedder offered, each as the view opened for it and its rights. +pub(crate) struct Preopens { + pub(crate) root: (Shared, Rights), + pub(crate) grants: Vec<(Name, GrantId, Shared, Rights)>, +} + +/// Why the guest's preopens could not be opened. +pub(crate) enum PreopensRefused { + /// The task root, at any width. + Root(Refusal), + Grants(GrantsRefused), +} + /// Why the task's named grants could not be offered. pub(crate) enum GrantsRefused { /// The listing itself was refused (`Pair` when the pair is gone). diff --git a/crates/wasi/src/filesystem/mod.rs b/crates/wasi/src/filesystem/mod.rs index b1d8325..2a71efe 100644 --- a/crates/wasi/src/filesystem/mod.rs +++ b/crates/wasi/src/filesystem/mod.rs @@ -1048,68 +1048,73 @@ impl types::HostDirectoryEntryStream for WasiCtxView<'_> { } } +/// The trap a refused preopen is answered with: `get-directories` has no +/// error to return, and an empty list would read as "no files" where the +/// service said why. +fn refused_preopens(refused: host::PreopensRefused) -> wasmtime::Error { + use host::{GrantsRefused, PreopensRefused}; + match refused { + PreopensRefused::Root(refusal) => { + wasmtime::format_err!("the task's root could not be opened for the guest: {refusal:?}") + } + PreopensRefused::Grants(GrantsRefused::Listing(refusal)) => wasmtime::format_err!( + "the task's named grants could not be listed for the guest: {refusal:?}" + ), + PreopensRefused::Grants(GrantsRefused::Grant(name, refusal)) => wasmtime::format_err!( + "the task's grant `{}` could not be opened for the guest: {refusal:?}", + name.as_str() + ), + } +} + impl preopens::Host for WasiCtxView<'_> { /// `.` — the task's root view — then, when the embedder offers them, /// the task's named grants under their names - /// ([`Filesystem::with_named_grants`]); or nothing, for a task without - /// a filesystem or whose root could not be opened. + /// ([`Filesystem::with_named_grants`]); nothing for a task without a + /// filesystem; a trap naming the refusal when one could not be opened. + /// Every call answers descriptors on the views the first call opened. async fn get_directories(&mut self) -> wasmtime::Result, String)>> { let Some(filesystem) = self.ctx.filesystem.as_ref() else { return Ok(Vec::new()); }; - let preopen = match Pinned::new(async move { - let host = filesystem.host().ok_or_else(off_thread)?; - let (root, rights) = host::preopen(host).await.map_err(|refusal| { - FsError::trap(wasmtime::format_err!( - "the task's root could not be opened for the guest: {refusal:?}" - )) - })?; - Descriptor::new(host.clone(), root, Origin::Root, Vec::new(), preopen_flags(rights)) - .ok_or_else(off_thread) - }) { - Ok(preopen) => preopen, - Err(_) => return Ok(Vec::new()), - }; - let root = preopen.await.map_err(FsError::into_trap)?; - let mut directories = vec![(self.table.push(root)?, String::from("."))]; - let offers_grants = filesystem.host().is_some_and(|host| host.offers_grants()); - if !offers_grants { - return Ok(directories); - } // Bound by a `match`, not awaited in one: the scrutinee's `Err` side // is the unpinned future, which must not live across the await. - let grants = match Pinned::new(async move { + let opened = match Pinned::new(async move { let host = filesystem.host().ok_or_else(off_thread)?; - let mut grants = Vec::new(); - let offered = host::preopen_grants(host).await.map_err(|refused| { - FsError::trap(match refused { - host::GrantsRefused::Listing(refusal) => wasmtime::format_err!( - "the task's named grants could not be listed for the guest: {refusal:?}" - ), - host::GrantsRefused::Grant(name, refusal) => wasmtime::format_err!( - "the task's grant `{}` could not be opened for the guest: {refusal:?}", - name.as_str() - ), - }) - })?; - for (name, grant, view, rights) in offered { + let preopens = host + .preopens() + .await + .map_err(|refused| FsError::trap(refused_preopens(refused)))?; + let mut directories = Vec::with_capacity(1 + preopens.grants.len()); + let (root, rights) = &preopens.root; + let root = Descriptor::new( + host.clone(), + root.clone(), + Origin::Root, + Vec::new(), + preopen_flags(*rights), + ) + .ok_or_else(off_thread)?; + directories.push((String::from("."), root)); + for (name, grant, view, rights) in &preopens.grants { let descriptor = Descriptor::new( host.clone(), - view, - Origin::Grant(grant), + view.clone(), + Origin::Grant(*grant), Vec::new(), - preopen_flags(rights), + preopen_flags(*rights), ) .ok_or_else(off_thread)?; - grants.push((name, descriptor)); + directories.push((String::from(name.as_str()), descriptor)); } - Ok::<_, FsError>(grants) + Ok::<_, FsError>(directories) }) { - Ok(grants) => grants, - Err(_) => return Ok(directories), + Ok(opened) => opened, + Err(_) => return Ok(Vec::new()), }; - for (name, descriptor) in grants.await.map_err(FsError::into_trap)? { - directories.push((self.table.push(descriptor)?, String::from(name.as_str()))); + let mut directories = Vec::new(); + for (name, descriptor) in opened.await.map_err(FsError::into_trap)? { + directories.push((self.table.push(descriptor)?, name)); } Ok(directories) } From eb94cfe0184cfbb917e14037ec82bf5c6b9d5fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:13:15 +0000 Subject: [PATCH 5/7] wasi: test the op that parks, and the result it hands back The slot driver's dropped result (d2fcddb) hid every preopen from a guest on Kore and passed every host test, because nothing on the host parks an op across turns. Two tests drive `FsHost::exec` over a host with no face, with ops that park once, as every op with a request in flight does on Kore: an op is answered with its result on the turn it finishes, and a caller dropped mid-op leaves its op to finish on the next caller's turn, which is still answered with its own. Both fail against the old arm, which answered `Ready(None)`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index 90e7ea7..b0873d6 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -523,3 +523,86 @@ pub(crate) async fn preopen(host: &FsHost) -> Result<(Shared, Rights), Refusal> .await .unwrap_or(Err(Refusal::Pair)) } + +#[cfg(test)] +mod tests { + use core::pin::pin; + use core::task::Waker; + + use super::*; + + /// A host with no face. The ops below run no verb; a `Custody` over an + /// absent face would refuse one as a lost pair. + fn faceless() -> FsHost { + FsHost { + cell: Rc::new(FsCell { + fs: Cell::new(None), + lost: Cell::new(false), + orphans: RefCell::new(Vec::new()), + }), + slot: RefCell::new(None), + chunk: 0, + grants: Cell::new(false), + preopens: RefCell::new(None), + } + } + + /// Pending once, then ready: an op parked on its reply, as every op + /// with a request in flight is on Kore. + async fn park_once() { + let mut parked = false; + poll_fn(|cx| { + if parked { + Poll::Ready(()) + } else { + parked = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + + /// An op that finishes on a later turn than the one that installed it, + /// and queues no view to close, is answered with its result. The driver + /// once answered `None` there, which the preopens read as a lost pair: + /// every guest on Kore was given no preopens. + #[test] + fn an_op_that_parks_hands_back_its_result() { + let host = faceless(); + let mut cx = Context::from_waker(Waker::noop()); + let mut call = pin!(host.exec(async |_: &mut Custody| { + park_once().await; + 7_u32 + })); + assert!(call.as_mut().poll(&mut cx).is_pending()); + assert_eq!(call.as_mut().poll(&mut cx), Poll::Ready(Some(7))); + assert!(host.slot.borrow().is_none()); + } + + /// A caller dropped after installing its op leaves the op to finish on + /// the next caller's turn, before that caller's own op is installed; the + /// next caller is still answered with its own result. + #[test] + fn a_dropped_callers_op_finishes_first_and_the_next_gets_its_own() { + let host = faceless(); + let mut cx = Context::from_waker(Waker::noop()); + let finished = Rc::new(Cell::new(false)); + { + let finished = finished.clone(); + let mut first = pin!(host.exec(async move |_: &mut Custody| { + park_once().await; + finished.set(true); + })); + assert!(first.as_mut().poll(&mut cx).is_pending()); + } + assert!(!finished.get()); + let mut second = pin!(host.exec(async |_: &mut Custody| { + park_once().await; + 11_u32 + })); + assert!(second.as_mut().poll(&mut cx).is_pending()); + assert!(finished.get()); + assert_eq!(second.as_mut().poll(&mut cx), Poll::Ready(Some(11))); + } +} From daee213e7b11b4b47ca20974ba30a57b2ffbdd01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:04:12 +0000 Subject: [PATCH 6/7] wasi: a named grant of one file is not a preopen `Fs::open_grant` enters a granted file as readily as a subtree root, and `get-directories` answered every named grant it opened as a directory preopen, so a task holding a named file grant would hand its guest a "directory" whose every walk fails with NotDirectory. A preopen is a directory: a grant whose root is not one is now closed and left out. It is not refused, since a refused preopen traps the guest (acad200) and this guest asked for nothing it lacks. The open loop records what it opened rather than inferring it from the last preopen's name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/filesystem/host.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/wasi/src/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index b0873d6..69e0755 100644 --- a/crates/wasi/src/filesystem/host.rs +++ b/crates/wasi/src/filesystem/host.rs @@ -385,7 +385,8 @@ impl Filesystem { /// embedder decides: a grant it holds for itself — a private data /// grant — is never offered unless it says so, and the grants offered /// are the task's, named as the task names them, with the rights the - /// service answers for each. + /// service answers for each. A grant of a single file is not offered: + /// a preopen is a directory. pub fn with_named_grants(self) -> Self { if let Some(host) = self.0.get() { host.grants.set(true); @@ -426,8 +427,11 @@ const PREOPEN_RIGHTS: [Rights; 4] = { /// answers for it, in the order the store lists them: what /// [`Filesystem::with_named_grants`] offers beside the root. The private /// root is not among them (it is `.`), nor is a grant without a name (a -/// guest could not spell it). A grant the service refuses outright is left -/// out; one it refuses only at a width is tried at the next. +/// guest could not spell it), nor a grant of a single file: a preopen is a +/// directory (`get-directories`), so a file grant's view is closed and left +/// out, not refused — the guest asked for nothing it lacks. A grant refused +/// only at a width is tried at the next; one refused at every width, or for +/// another reason, refuses them all. pub(crate) async fn preopen_grants( host: &FsHost, ) -> Result, GrantsRefused> { @@ -454,22 +458,27 @@ pub(crate) async fn preopen_grants( let mut preopens = Vec::with_capacity(named.len()); for (name, grant) in named { let mut refused = Refusal::Pair; + let mut opened = None; for rights in PREOPEN_RIGHTS { let root = pair .call(async move |fs: &mut Face| fs.open_grant(grant, rights).await) .await; match root { Ok(root) => { - preopens.push((name, grant, pair.own(root), rights)); - refused = Refusal::Fs(FsError::AccessDenied); + opened = Some((root, rights)); break; } Err(error @ Refusal::Fs(FsError::AccessDenied)) => refused = error, Err(error) => return Err(GrantsRefused::Grant(name, error)), } } - if preopens.last().is_none_or(|(last, ..)| *last != name) { + let Some((root, rights)) = opened else { return Err(GrantsRefused::Grant(name, refused)); + }; + if root.is_directory() { + preopens.push((name, grant, pair.own(root), rights)); + } else { + pair.close(root).await; } } Ok(preopens) From 1f2eb694e1c7f386c9a133e30710b8417abd3391 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:35:49 +0000 Subject: [PATCH 7/7] wasi: tell the guest the terminal its embedder declared `wasi:cli/terminal-stdin`, `-stdout` and `-stderr` answered "no terminal" unconditionally, so a guest's `isatty` was always false and CPython never opened its REPL without `-i` (and block-buffered its output on the shell). Whether the far end of the standard streams is a terminal is something the embedder knows and the streams do not: on Kore it is the profile the launcher declares on the process stream page. `WasiCtx::with_terminal` takes that declaration; with it the three getters hand the guest a terminal handle (a resource with no methods: holding one is the answer), and without it they keep answering `None`, as for redirected stdio. All three streams share the one process stream, so they answer together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmEMGSFqb56Tcsvuk9PQPx --- crates/wasi/src/bindings.rs | 2 + crates/wasi/src/cli.rs | 83 +++++++++++++++++++++++++++++-------- crates/wasi/src/lib.rs | 17 ++++++++ 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/crates/wasi/src/bindings.rs b/crates/wasi/src/bindings.rs index f43c5d1..e7110e1 100644 --- a/crates/wasi/src/bindings.rs +++ b/crates/wasi/src/bindings.rs @@ -29,6 +29,8 @@ wasmtime::component::bindgen!({ "wasi:io/error": wasmtime_wasi_io::bindings::wasi::io::error, "wasi:filesystem/types.descriptor": crate::filesystem::Descriptor, "wasi:filesystem/types.directory-entry-stream": crate::filesystem::DirectoryEntryStream, + "wasi:cli/terminal-input.terminal-input": crate::cli::TerminalInput, + "wasi:cli/terminal-output.terminal-output": crate::cli::TerminalOutput, }, require_store_data_send: true, }); diff --git a/crates/wasi/src/cli.rs b/crates/wasi/src/cli.rs index 7f6d462..74db66e 100644 --- a/crates/wasi/src/cli.rs +++ b/crates/wasi/src/cli.rs @@ -86,41 +86,90 @@ impl stderr::Host for WasiCtxView<'_> { } } -// The process stream is a pipe to the launcher, which does the terminal's -// line discipline itself: the guest is not attached to a terminal, and says -// so, the way a program with redirected stdio does. +/// A guest's handle on its terminal input. The interface has no methods: holding +/// one *is* the answer to "is stdin a terminal?". +pub struct TerminalInput; + +/// A guest's handle on a terminal output, likewise only its own answer. +pub struct TerminalOutput; + +// The launcher does the line discipline: a terminal's stream arrives a line at +// a time, already edited and echoed. Whether its far end is a terminal is the +// embedder's declaration (`WasiCtx::with_terminal`): Kore's shell declares its +// interactive relay one, and a launcher that captures the output does not, so +// the guest is told the truth either way. All three streams share the one +// process stream, so they answer together. impl terminal_input::Host for WasiCtxView<'_> {} impl terminal_input::HostTerminalInput for WasiCtxView<'_> { - fn drop(&mut self, r: Resource) -> wasmtime::Result<()> { + fn drop(&mut self, r: Resource) -> wasmtime::Result<()> { self.table.delete(r)?; Ok(()) } } impl terminal_output::Host for WasiCtxView<'_> {} impl terminal_output::HostTerminalOutput for WasiCtxView<'_> { - fn drop(&mut self, r: Resource) -> wasmtime::Result<()> { + fn drop(&mut self, r: Resource) -> wasmtime::Result<()> { self.table.delete(r)?; Ok(()) } } impl terminal_stdin::Host for WasiCtxView<'_> { - fn get_terminal_stdin( - &mut self, - ) -> wasmtime::Result>> { - Ok(None) + fn get_terminal_stdin(&mut self) -> wasmtime::Result>> { + if !self.ctx.terminal { + return Ok(None); + } + Ok(Some(self.table.push(TerminalInput)?)) } } impl terminal_stdout::Host for WasiCtxView<'_> { - fn get_terminal_stdout( - &mut self, - ) -> wasmtime::Result>> { - Ok(None) + fn get_terminal_stdout(&mut self) -> wasmtime::Result>> { + self.terminal_output() } } impl terminal_stderr::Host for WasiCtxView<'_> { - fn get_terminal_stderr( - &mut self, - ) -> wasmtime::Result>> { - Ok(None) + fn get_terminal_stderr(&mut self) -> wasmtime::Result>> { + self.terminal_output() + } +} + +impl WasiCtxView<'_> { + /// stdout's or stderr's terminal, when the streams are a terminal's. + fn terminal_output(&mut self) -> wasmtime::Result>> { + if !self.ctx.terminal { + return Ok(None); + } + Ok(Some(self.table.push(TerminalOutput)?)) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use super::*; + use crate::{Wasi, WasiCtx, WasiView}; + + /// Each standard stream answers "is this a terminal?" exactly as the + /// embedder declared, and a terminal handle the guest drops leaves the + /// table. + #[test] + fn the_guest_is_told_the_terminal_its_embedder_declared() { + for terminal in [false, true] { + let mut wasi = Wasi::new(WasiCtx::new(Vec::new()).with_terminal(terminal)); + let mut view = wasi.wasi(); + let stdin = terminal_stdin::Host::get_terminal_stdin(&mut view).unwrap(); + let stdout = terminal_stdout::Host::get_terminal_stdout(&mut view).unwrap(); + let stderr = terminal_stderr::Host::get_terminal_stderr(&mut view).unwrap(); + assert_eq!( + (stdin.is_some(), stdout.is_some(), stderr.is_some()), + (terminal, terminal, terminal) + ); + if let (Some(stdin), Some(stdout), Some(stderr)) = (stdin, stdout, stderr) { + terminal_input::HostTerminalInput::drop(&mut view, stdin).unwrap(); + terminal_output::HostTerminalOutput::drop(&mut view, stdout).unwrap(); + terminal_output::HostTerminalOutput::drop(&mut view, stderr).unwrap(); + } + assert!(wasi.table.is_empty()); + } } } diff --git a/crates/wasi/src/lib.rs b/crates/wasi/src/lib.rs index 58a29f2..608fe6e 100644 --- a/crates/wasi/src/lib.rs +++ b/crates/wasi/src/lib.rs @@ -84,6 +84,9 @@ pub struct WasiCtx { pub(crate) rng: kore::random::SystemRng, /// `None`: the stream getters trap. pub(crate) streams: Option, + /// The embedder declared the streams' far end a terminal + /// ([`with_terminal`](Self::with_terminal)). + pub(crate) terminal: bool, /// `None`: `get-directories` is empty. pub(crate) filesystem: Option, } @@ -96,6 +99,7 @@ impl WasiCtx { args, rng: kore::random::SystemRng::new(), streams: None, + terminal: false, filesystem: None, } } @@ -106,6 +110,19 @@ impl WasiCtx { self } + /// Whether the far end of the standard streams is a terminal — the + /// guest's `wasi:cli/terminal-*` answers, and so its `isatty`. The + /// embedder knows and the streams do not: on Kore it is the profile + /// the launcher declared on the stream page + /// (`ProcessStdioChild::profile`), a terminal for the shell's + /// interactive relay and not for a launcher that captures the output. + /// Undeclared, the guest is told it has no terminal, as a program with + /// redirected stdio is. + pub fn with_terminal(mut self, terminal: bool) -> Self { + self.terminal = terminal; + self + } + /// The guest's filesystem: `filesystem`'s root, as the preopen `.`. pub fn with_filesystem(mut self, filesystem: Filesystem) -> Self { self.filesystem = Some(filesystem);