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/filesystem/host.rs b/crates/wasi/src/filesystem/host.rs index 97feb31..69e0755 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,15 @@ 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, + /// 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 { @@ -215,6 +225,30 @@ impl FsHost { self.chunk } + pub(crate) fn offers_grants(&self) -> bool { + 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 { @@ -291,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 { @@ -305,7 +347,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 +371,29 @@ impl Filesystem { }), slot: RefCell::new(None), chunk, + grants: Cell::new(false), + preopens: RefCell::new(None), }); 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. 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); + } + self + } + pub(crate) fn host(&self) -> Option<&Rc> { self.0.get() } @@ -342,30 +407,211 @@ 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() } } -/// 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)> { +/// 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), 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> { host.exec(async |pair: &mut Custody| { - for rights in [MUTATE.union(Rights::WRITE), MUTATE, WRITE, Rights::READ] { + // 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 + .map_err(GrantsRefused::Listing)?; + 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) => { + opened = Some((root, rights)); + break; + } + Err(error @ Refusal::Fs(FsError::AccessDenied)) => refused = error, + Err(error) => return Err(GrantsRefused::Grant(name, error)), + } + } + 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) + }) + .await + .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). + 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. `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)) +} + +#[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))); + } } diff --git a/crates/wasi/src/filesystem/mod.rs b/crates/wasi/src/filesystem/mod.rs index cb0b513..2a71efe 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}; @@ -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 { @@ -204,13 +213,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 +242,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 +420,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 +432,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 +492,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. @@ -1019,24 +1048,74 @@ 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 — 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`]); 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()?; - let (root, rights) = host::preopen(host).await?; - Descriptor::new(host.clone(), root, Vec::new(), preopen_flags(rights)) + // 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 opened = match Pinned::new(async move { + let host = filesystem.host().ok_or_else(off_thread)?; + 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.clone(), + Origin::Grant(*grant), + Vec::new(), + preopen_flags(*rights), + ) + .ok_or_else(off_thread)?; + directories.push((String::from(name.as_str()), descriptor)); + } + Ok::<_, FsError>(directories) }) { - Ok(preopen) => preopen, + Ok(opened) => opened, Err(_) => return Ok(Vec::new()), }; - Ok(match preopen.await { - Some(root) => vec![(self.table.push(root)?, String::from("."))], - None => Vec::new(), - }) + 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) } } 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 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);