Skip to main content

sim_web_shell/
cli.rs

1//! Loadable CLI claims for the web shell surfaces.
2
3use std::sync::Arc;
4
5use sim_codec_lisp::LispCodecLib;
6use sim_kernel::{
7    AbiVersion, Args, CORE_FUNCTION_CLASS_ID, Callable, CapabilityName, ClassRef, CodecId, Cx,
8    Error, Export, Expr, Lib, LibManifest, LibTarget, Linker, LoadCx, Object, ObjectCompat, Result,
9    Symbol, Value, Version, read_eval_capability,
10};
11use sim_lib_server::{CookbookCapabilityProfile, CookbookWebState};
12use sim_run_core::{Bootloader, RuntimeConfigState, cli_main_entrypoint_symbol};
13
14use crate::serve::{ServeConfig, serve_with_cx};
15
16/// Loadable lib that claims the `atelier` command-line verb.
17pub struct AtelierCliLib;
18
19/// Loadable lib that claims the `browse` command-line verb.
20pub struct BrowseCliLib;
21
22impl Lib for AtelierCliLib {
23    fn manifest(&self) -> LibManifest {
24        cli_manifest("atelier", "cli/main/atelier")
25    }
26
27    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
28        register_cli_entrypoint(cx, linker, "atelier")
29    }
30}
31
32impl Lib for BrowseCliLib {
33    fn manifest(&self) -> LibManifest {
34        cli_manifest("browse", "cli/main/browse")
35    }
36
37    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
38        register_cli_entrypoint(cx, linker, "browse")
39    }
40}
41
42fn cli_manifest(id: &str, entrypoint: &str) -> LibManifest {
43    LibManifest {
44        id: Symbol::new(id),
45        version: Version(env!("CARGO_PKG_VERSION").to_owned()),
46        abi: AbiVersion { major: 0, minor: 1 },
47        target: LibTarget::HostRegistered,
48        requires: Vec::new(),
49        capabilities: Vec::new(),
50        exports: vec![Export::Function {
51            symbol: symbol_from_slash(entrypoint),
52            function_id: None,
53        }],
54    }
55}
56
57fn register_cli_entrypoint(
58    cx: &mut LoadCx,
59    linker: &mut Linker<'_>,
60    verb: &'static str,
61) -> Result<()> {
62    linker.function_value(
63        Symbol::qualified("cli", format!("main/{verb}")),
64        cx.factory()
65            .opaque(Arc::new(WebShellCliEntrypoint { verb }))?,
66    )?;
67    Ok(())
68}
69
70#[derive(Clone)]
71struct WebShellCliEntrypoint {
72    verb: &'static str,
73}
74
75impl Object for WebShellCliEntrypoint {
76    fn display(&self, _cx: &mut Cx) -> Result<String> {
77        Ok(format!("#<function cli/main/{}>", self.verb))
78    }
79
80    fn as_any(&self) -> &dyn std::any::Any {
81        self
82    }
83}
84
85impl ObjectCompat for WebShellCliEntrypoint {
86    fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
87        if let Some(value) = cx
88            .registry()
89            .class_by_symbol(&Symbol::qualified("core", "Function"))
90        {
91            return Ok(value.clone());
92        }
93        cx.factory().class_stub(
94            CORE_FUNCTION_CLASS_ID,
95            Symbol::qualified("core", "Function"),
96        )
97    }
98
99    fn as_callable(&self) -> Option<&dyn Callable> {
100        Some(self)
101    }
102}
103
104impl Callable for WebShellCliEntrypoint {
105    fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
106        verify_cli_envelope(cx, &args, self.verb)?;
107        cx.factory().bool(true)
108    }
109}
110
111fn verify_cli_envelope(cx: &mut Cx, args: &Args, verb: &str) -> Result<()> {
112    let envelope = args
113        .values()
114        .first()
115        .ok_or_else(|| Error::Eval(format!("cli/main/{verb} expects a CLI envelope")))?;
116    let envelope_verb = envelope_string_field(cx, envelope, "verb")?;
117    if envelope_verb != verb {
118        return Err(Error::Eval(format!(
119            "cli/main/{verb} received verb {envelope_verb}"
120        )));
121    }
122    let payload_args = envelope_args(cx, envelope)?;
123    if payload_args.first().map(String::as_str) != Some(verb) {
124        return Err(Error::Eval(format!(
125            "cli/main/{verb} expects the first payload argument to be {verb}"
126        )));
127    }
128    Ok(())
129}
130
131fn envelope_string_field(cx: &mut Cx, envelope: &Value, field: &str) -> Result<String> {
132    let Some(table) = envelope.object().as_table_impl() else {
133        return Err(Error::Eval("CLI envelope is not a table".to_owned()));
134    };
135    match table.get(cx, Symbol::new(field))?.object().as_expr(cx)? {
136        Expr::String(text) => Ok(text),
137        Expr::Nil => Err(Error::Eval(format!("CLI envelope field {field} is nil"))),
138        other => Err(Error::Eval(format!(
139            "CLI envelope field {field} is not a string: {other:?}"
140        ))),
141    }
142}
143
144fn envelope_args(cx: &mut Cx, envelope: &Value) -> Result<Vec<String>> {
145    let Some(table) = envelope.object().as_table_impl() else {
146        return Err(Error::Eval("CLI envelope is not a table".to_owned()));
147    };
148    let value = table.get(cx, Symbol::new("args"))?;
149    let Some(list) = value.object().as_list() else {
150        return Err(Error::Eval(
151            "CLI envelope field args is not a list".to_owned(),
152        ));
153    };
154    list.to_vec(cx, Some(64))?
155        .into_iter()
156        .map(|value| match value.object().as_expr(cx)? {
157            Expr::String(text) => Ok(text),
158            other => Err(Error::Eval(format!(
159                "CLI payload argument is not a string: {other:?}"
160            ))),
161        })
162        .collect()
163}
164
165fn symbol_from_slash(text: &str) -> Symbol {
166    match text.split_once('/') {
167        Some((head, tail)) => Symbol::qualified(head, tail),
168        None => Symbol::new(text),
169    }
170}
171
172// ---------------------------------------------------------------------------
173// The loadable `serve` verb: boots the web shell through the sim-run bootloader.
174// ---------------------------------------------------------------------------
175
176/// The verb the bootloader dispatches to serve the web shell (`sim serve ...`).
177pub const WEB_SERVE_VERB: &str = "serve";
178
179/// Returns the function symbol exported for the bootloader handoff.
180pub fn web_serve_entrypoint_symbol() -> Symbol {
181    cli_main_entrypoint_symbol(WEB_SERVE_VERB)
182}
183
184/// Builds host-owned cookbook state from the effective boot config.
185pub type CookbookStateFactory = Arc<dyn Fn(&RuntimeConfigState) -> CookbookWebState + Send + Sync>;
186
187/// Registers the `codec/lisp` boot codec and the web-shell `serve` verb onto an
188/// existing [`Bootloader`], returning it for further composition. A downstream binary
189/// can stack this with other serve libraries (e.g. MCP) onto one bootloader.
190pub fn configure_web_bootloader(loader: Bootloader) -> Bootloader {
191    configure_web_bootloader_base(loader).host_verb(WEB_SERVE_VERB, "lib/web-serve", || {
192        Box::new(WebServeLib::new())
193    })
194}
195
196/// Registers the web-shell `serve` verb with a host-provided cookbook state
197/// factory. The supplied config library ids are read before the serve library is
198/// instantiated, so the factory sees the effective config tables it needs.
199pub fn configure_web_bootloader_with_cookbook(
200    loader: Bootloader,
201    config_libs: Vec<Symbol>,
202    cookbook: CookbookStateFactory,
203) -> Bootloader {
204    configure_web_bootloader_base(loader).host_verb_with_config(
205        WEB_SERVE_VERB,
206        "lib/web-serve",
207        config_libs,
208        move |config| Box::new(WebServeLib::with_cookbook(cookbook(config))),
209    )
210}
211
212fn configure_web_bootloader_base(loader: Bootloader) -> Bootloader {
213    // Seat the cookbook eval Cx with the whole capability profile at the trusted
214    // host boundary, where the bootloader holds the boot session's GrantSeat.
215    // The profile grants pure/offline/deterministic vocabulary and omits live
216    // effectful capabilities, so recipes that demand a denied capability fail
217    // closed. run_recipe still gates each run on read-eval, so the capability is
218    // required, not ambient.
219    let loader = CookbookCapabilityProfile::granted()
220        .into_iter()
221        .fold(loader, |loader, capability| {
222            loader.with_capability(capability)
223        });
224    // Modeled glasses voice recipes need host authority for the consent gate,
225    // but recipe eval is still diminished by explicit allow-capability tags.
226    let loader = loader.with_capability(CapabilityName::new("glasses/mic"));
227    loader.host_lib("codec/lisp", || {
228        Box::new(LispCodecLib::new(CodecId(1)).expect("lisp boot codec"))
229    })
230}
231
232/// A standalone [`Bootloader`] pre-configured to serve the web shell: the `codec/lisp`
233/// boot codec plus the `serve` verb. The thin `sim-web-shell` binary is just
234/// `web_bootloader().run(..)`.
235pub fn web_bootloader() -> Bootloader {
236    configure_web_bootloader(Bootloader::standard())
237}
238
239/// Loadable library exporting the web-shell `serve` entrypoint.
240pub struct WebServeLib {
241    cookbook: Option<Arc<CookbookWebState>>,
242}
243
244impl WebServeLib {
245    /// Builds the standalone web-serve library.
246    pub fn new() -> Self {
247        Self { cookbook: None }
248    }
249
250    /// Builds a web-serve library with host-provided cookbook state.
251    pub fn with_cookbook(cookbook: CookbookWebState) -> Self {
252        Self {
253            cookbook: Some(Arc::new(cookbook)),
254        }
255    }
256}
257
258impl Default for WebServeLib {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl Lib for WebServeLib {
265    fn manifest(&self) -> LibManifest {
266        LibManifest {
267            id: Symbol::qualified("lib", "web-serve"),
268            version: Version(env!("CARGO_PKG_VERSION").to_owned()),
269            abi: AbiVersion { major: 0, minor: 1 },
270            target: LibTarget::HostRegistered,
271            requires: Vec::new(),
272            capabilities: vec![read_eval_capability()],
273            exports: vec![Export::Function {
274                symbol: web_serve_entrypoint_symbol(),
275                function_id: None,
276            }],
277        }
278    }
279
280    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
281        linker.function_value(
282            web_serve_entrypoint_symbol(),
283            cx.factory().opaque(Arc::new(WebServeEntrypoint {
284                cookbook: self.cookbook.clone(),
285            }))?,
286        )?;
287        Ok(())
288    }
289}
290
291#[derive(Clone)]
292struct WebServeEntrypoint {
293    cookbook: Option<Arc<CookbookWebState>>,
294}
295
296impl Object for WebServeEntrypoint {
297    fn display(&self, _cx: &mut Cx) -> Result<String> {
298        Ok("cli/main/serve".to_owned())
299    }
300
301    fn as_any(&self) -> &dyn std::any::Any {
302        self
303    }
304}
305
306impl ObjectCompat for WebServeEntrypoint {
307    fn as_callable(&self) -> Option<&dyn Callable> {
308        Some(self)
309    }
310}
311
312impl Callable for WebServeEntrypoint {
313    fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
314        // Parse `--addr` / `--atelier-root` from the boot envelope (skipping the
315        // `serve` verb), then run the blocking HTTP loop in the bootloader cx.
316        let mut config = match args.values().first() {
317            Some(envelope) => {
318                let payload = envelope_args(cx, envelope)?;
319                parse_serve_config(payload.into_iter().skip(1))?
320            }
321            None => ServeConfig::default(),
322        };
323        config.cookbook.clone_from(&self.cookbook);
324        serve_with_cx(cx, &config)
325            .map_err(|err| Error::Eval(format!("web serve failed: {err}")))?;
326        cx.factory().bool(true)
327    }
328}
329
330/// Parse the serve envelope arguments, failing closed on malformed input: a
331/// bare `--addr`/`--atelier-root` with no value, or any unknown flag/positional,
332/// is an error rather than a silently-ignored argument (so `--add 0.0.0.0:80`
333/// cannot quietly leave the shell bound to loopback).
334fn parse_serve_config(args: impl Iterator<Item = String>) -> Result<ServeConfig> {
335    let mut config = ServeConfig::default();
336    let mut iter = args;
337    while let Some(arg) = iter.next() {
338        match arg.as_str() {
339            "--addr" => {
340                config.addr = iter
341                    .next()
342                    .ok_or_else(|| Error::Eval("--addr requires a value".to_owned()))?;
343            }
344            other if other.starts_with("--addr=") => {
345                config.addr = other["--addr=".len()..].to_owned();
346            }
347            "--atelier-root" => {
348                config.atelier_root = iter
349                    .next()
350                    .ok_or_else(|| Error::Eval("--atelier-root requires a value".to_owned()))?
351                    .into();
352            }
353            other if other.starts_with("--atelier-root=") => {
354                config.atelier_root = other["--atelier-root=".len()..].into();
355            }
356            "--dry-run" => {
357                config.dry_run = true;
358            }
359            other => {
360                return Err(Error::Eval(format!("unknown serve argument: {other}")));
361            }
362        }
363    }
364    Ok(config)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::parse_serve_config;
370
371    fn parse(args: &[&str]) -> super::Result<super::ServeConfig> {
372        parse_serve_config(args.iter().map(|a| (*a).to_owned()))
373    }
374
375    #[test]
376    fn missing_addr_value_errors() {
377        let err = parse(&["--addr"]).expect_err("bare --addr must error");
378        assert!(err.to_string().contains("--addr requires a value"));
379    }
380
381    #[test]
382    fn missing_atelier_root_value_errors() {
383        let err = parse(&["--atelier-root"]).expect_err("bare --atelier-root must error");
384        assert!(err.to_string().contains("--atelier-root requires a value"));
385    }
386
387    #[test]
388    fn unknown_flag_errors() {
389        // A typo such as `--add` must fail visibly, not silently bind loopback.
390        let err = parse(&["--add", "0.0.0.0:80"]).expect_err("unknown flag must error");
391        assert!(err.to_string().contains("unknown serve argument: --add"));
392    }
393
394    #[test]
395    fn unknown_positional_errors() {
396        let err = parse(&["serve-extra"]).expect_err("stray positional must error");
397        assert!(
398            err.to_string()
399                .contains("unknown serve argument: serve-extra")
400        );
401    }
402
403    #[test]
404    fn dry_run_still_succeeds() {
405        let config = parse(&["--dry-run"]).expect("--dry-run must parse");
406        assert!(config.dry_run);
407    }
408
409    #[test]
410    fn addr_and_atelier_root_parse() {
411        let config = parse(&["--addr", "127.0.0.1:9000", "--atelier-root", "/tmp/atelier"])
412            .expect("valid args must parse");
413        assert_eq!(config.addr, "127.0.0.1:9000");
414        assert_eq!(config.atelier_root.to_str(), Some("/tmp/atelier"));
415        assert!(!config.dry_run);
416    }
417
418    #[test]
419    fn inline_addr_value_parses() {
420        let config = parse(&["--addr=127.0.0.1:9100"]).expect("inline addr must parse");
421        assert_eq!(config.addr, "127.0.0.1:9100");
422    }
423}