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