Sign in

    This version of the module is deprecated: Superseded by the shell package in moonbitlang/async (import moonbitlang/async/shell): use @shell.Cmd with run, status, output and each_line, and @shell.glob. This module gets no further updates.

    myshell

    A small shell-free process EDSL for MoonBit

    process
    pipeline
    async
    sandbox
    agent
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    2 months ago
    Downloads
    7K

    #myshell

    bobzhang/myshell is a small, shell-free process EDSL built on moonbitlang/async. It is meant for sandboxed programs and agents that need a familiar process API without receiving a shell as ambient authority.

    #The invariant

    Cmd never invokes a shell. The executable and argument vector stay separate, and every argument is passed literally. |, >, &&, $(), and * have no special meaning. Use Pipeline for pipes and ordinary MoonBit for control flow. Embedded NUL characters in process metadata are rejected before spawning rather than being truncated by an operating-system argv boundary.

    The library supports the wasm and native targets. Its preferred target is wasm, where process creation is delegated through the host operations used by moonbitlang/async/process.

    To exercise the included smoke program under MoonBit's deny-by-default Wasm policy, with only process spawning enabled:

    moon runwasm --experimental-policy examples/moonrun-policy.json cmd/smoke

    #Install

    moon add bobzhang/myshell

    Add the async runtime and this library to the executable package:

    ///|
    import {
    "bobzhang/myshell",
    "moonbitlang/async",
    }

    Use an async fn main; MoonBit async code does not use an await keyword.

    #1. Run one command

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Cmd::Cmd("printf", ["hello"]).output()
    assert_eq(output.exit_code, 0)
    assert_eq(output.stdout, "hello")
    }

    #2. Pass shell characters literally

    This prints the characters; it does not execute echo and does not create a pipe.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Cmd::Cmd("printf", ["%s", "$(echo no) | *"]).output()
    assert_eq(output.stdout, "$(echo no) | *")
    }

    #3. Add arguments

    Builders return copies, so a reusable base command is not mutated.

    ///|
    test {
    let git = Cmd::Cmd("git", [])
    let status = git.args(["status", "--short"])
    let diff = git.arg("diff")
    debug_inspect(status.arguments(), content="[\"status\", \"--short\"]")
    debug_inspect(diff.arguments(), content="[\"diff\"]")
    debug_inspect(git.arguments(), content="[]")
    }

    #4. Pipe commands

    Every stage is separately visible to the process host. The stages use real operating-system pipes and run concurrently in one structured task group.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let plan = Pipeline::Pipeline([
    Cmd("printf", ["alpha\nbeta\n"]),
    Cmd("grep", ["beta"]),
    Cmd("tr", ["a-z", "A-Z"]),
    ])
    let output = plan.output()
    assert_eq(output.stdout, "BETA\n")
    assert_eq(output.stage_exit_codes, [0, 0, 0])
    }

    The equivalent incremental form is deliberately ordinary:

    ///|
    test {
    let plan = Cmd::Cmd("printf", ["hello"])
    .pipe(Cmd("cat", []))
    .pipe(Cmd("wc", ["-c"]))
    assert_eq(plan.commands().length(), 3)
    }

    #5. Supply standard input

    Standard input is closed by default, so non-interactive runs cannot accidentally wait on ambient input. stdin on the first stage also feeds a pipeline.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let plan = Cmd::Cmd("cat", []).stdin("hello").pipe(Cmd("tr", ["a-z", "A-Z"]))
    let output = plan.output()
    assert_eq(output.stdout, "HELLO")
    }

    Use stdin_bytes when the input is not text:

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Cmd::Cmd("cat", []).stdin_bytes(b"\x00\xff").output()
    assert_eq(output.stdout_bytes, b"\x00\xff")
    }

    #6. Set cwd and environment

    ///|
    test {
    let command = Cmd::Cmd("tool", ["check"])
    .cwd("workspace")
    .env("NO_COLOR", "1")
    debug_inspect(command.working_directory(), content="Some(\"workspace\")")
    assert_eq(command.environment()["NO_COLOR"], "1")
    }

    Use .clear_env() before .env(...) to start with an empty environment.

    #7. Run without capturing output

    Use status when stdout and stderr should be inherited by the current process. It returns only the exit code and has no capture limit.

    ///|
    #cfg(not(platform="windows"))
    async test {
    assert_eq(Cmd::Cmd("false", []).status(), 1)
    }

    Process::run and Process::pipeline remain as deprecated compatibility entry points for 0.1 callers.

    #8. Inspect exit status

    Cmd::output does not turn a non-zero exit status into an exception. Use ordinary MoonBit control flow or call check() when failure should raise.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Cmd::Cmd("false", []).output()
    if !output.success() {
    assert_eq(output.exit_code, 1)
    }
    }

    #9. Pipeline status uses pipefail

    exit_code is the rightmost non-zero stage status, or zero when all stages succeed. Every individual status remains available in stage_exit_codes.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Pipeline::Pipeline([Cmd("false", []), Cmd("cat", [])]).output()
    assert_eq(output.exit_code, 1)
    assert_eq(output.stage_exit_codes, [1, 0])
    }

    #10. Capture stderr

    For pipelines, stage_stderr preserves one string per stage and stderr concatenates them in stage order. Raw output remains available as stdout_bytes, stderr_bytes, and stage_stderr_bytes; the text fields use lossy UTF-8 decoding so arbitrary process output cannot cancel sibling stages.

    ///|
    #cfg(not(platform="windows"))
    async test {
    let output = Cmd::Cmd("/usr/bin/grep", [
    "needle", "/definitely/not/a/real/myshell-file",
    ]).output()
    assert_true(!output.stderr.is_empty())
    assert_eq(output.stage_stderr.length(), 1)
    }

    #11. Add a timeout

    A timeout cancels the structured task and immediately kills each direct child. It is not a process-tree deadline: descendants must be contained and reaped by the host's native process sandbox.

    ///|
    #cfg(not(platform="windows"))
    async test {
    try Cmd::Cmd("sleep", ["1"]).output(timeout_ms=20) catch {
    @async.TimeoutError => ()
    _ => fail("expected TimeoutError")
    } noraise {
    _ => fail("expected TimeoutError")
    }
    }

    Captured stdout plus stderr is limited to 8 MiB by default. This prevents an untrusted child from growing the Wasm instance without bound. Override it when needed:

    ///|
    let output = command.output(max_output_bytes=32 * 1024 * 1024)

    If all captured streams together exceed the limit, execution raises ProcessError::OutputLimitExceeded and cancels the structured process group.

    #One-file use

    After the package is published, a complete sandboxed probe can be run without creating a project:

    moon run -e 'import { "bobzhang/myshell", "moonbitlang/async", } async fn main { let output = @myshell.Pipeline::Pipeline([ @myshell.Cmd::Cmd("/usr/bin/printf", ["hello\nworld\n"]).clear_env(), @myshell.Cmd::Cmd("/usr/bin/grep", ["world"]).clear_env(), ]).output() print(output.stdout) }'

    #Security boundary

    This package removes shell parsing and keeps each process invocation structured; it does not decide which executables or effects are safe. On wasm, the host still owns executable authorization and the native process sandbox. A strong deployment should grant each child only the filesystem, network, environment, and secret capabilities required for that invocation.

    MoonBit's current moonrun policy gates process spawning as a boolean. Enabling process.spawn does not apply the policy's filesystem or network restrictions to the native child; the child receives host-user ambient access. Production agent runtimes therefore need the separate native exec-sandbox described in the architecture, with child capabilities no greater than the parent grant.

    ProcessError

    pub(all) suberror ProcessError {
    EmptyProgram
    EmptyPipeline
    InvalidEnvironmentName(String)
    InvalidOutputLimit(Int)
    OutputLimitExceeded(stream~ : String, limit~ : Int)
    NulByte(String)
    StdinOnNonFirstStage(Int)
    CommandFailed(Output)
    } derive(
    Debug
    )

    Errors reported by the process EDSL itself.

    Cmd

    A shell-free process description.

    program is an executable name or path. Every element of arguments is passed as one literal argument. Shell operators such as |, >, &&, $(), and * have no special meaning.

    Cmd::Cmd

    #alias(new, deprecated="Use Cmd::Cmd instead")
    fn Cmd::Cmd(program : String, arguments : Array[String]) -> Cmd

    Create a process description from an executable and its literal arguments.

    Example

    test {
    let cmd = Cmd::Cmd("git", ["status", "--short"])
    inspect(cmd.program(), content="git")
    debug_inspect(cmd.arguments(), content="[\"status\", \"--short\"]")
    }

    Cmd::arg

    fn Cmd::arg(self : Cmd, argument : String) -> Cmd

    Return a copy with one literal argument appended.

    Cmd::args

    fn Cmd::args(self : Cmd, arguments : ArrayView[String]) -> Cmd

    Return a copy with literal arguments appended.

    Cmd::arguments

    fn Cmd::arguments(self : Cmd) -> Array[String]

    Return the literal argument vector.

    Cmd::clear_env

    fn Cmd::clear_env(self : Cmd) -> Cmd

    Return a copy that does not inherit the parent environment.

    Variables added with env or envs are still supplied.

    Cmd::cwd

    fn Cmd::cwd(self : Cmd, directory : String) -> Cmd

    Return a copy that runs in directory.

    Cmd::env

    fn Cmd::env(self : Cmd, name : String, value : String) -> Cmd

    Return a copy with one environment variable set for the child process.

    Cmd::environment

    fn Cmd::environment(self : Cmd) -> Map[String, String]

    Return a copy of the configured environment additions.

    Cmd::envs

    fn Cmd::envs(self : Cmd, environment : Map[String, String]) -> Cmd

    Return a copy with all entries in environment set for the child process.

    Cmd::inherits_environment

    fn Cmd::inherits_environment(self : Cmd) -> Bool

    Whether the child inherits the parent environment.

    Cmd::no_console_window

    fn Cmd::no_console_window(self : Cmd) -> Cmd

    Return a copy with creation of a console window disabled on Windows.

    This option has no effect on other platforms.

    Cmd::output

    async fn Cmd::output(self : Cmd, timeout_ms? : Int, max_output_bytes? : Int) -> Output

    Execute this command and capture stdout and stderr.

    Standard input is closed by default. Use Cmd::stdin to supply text. Captured stdout and stderr share max_output_bytes. When timeout_ms expires, the direct child is killed immediately; descendants require enforcement by the host's native process sandbox.

    Cmd::pipe

    fn Cmd::pipe(self : Cmd, next : Cmd) -> Pipeline

    Start a two-command pipeline.

    Cmd::program

    fn Cmd::program(self : Cmd) -> String

    Return the executable name or path.

    Cmd::standard_input

    fn Cmd::standard_input(self : Cmd) -> String?

    Return the configured standard input text, if any.

    Cmd::standard_input_bytes

    fn Cmd::standard_input_bytes(self : Cmd) -> Bytes?

    Return the configured standard input bytes, if stdin_bytes was used.

    Cmd::status

    async fn Cmd::status(self : Cmd, timeout_ms? : Int) -> Int

    Execute this command without capturing stdout or stderr.

    The child inherits the current process's stdout and stderr, and this method returns only its exit status. Standard input is closed by default; stdin and stdin_bytes still apply. Use output when captured data is needed.

    Cmd::stdin

    fn Cmd::stdin(self : Cmd, input : String) -> Cmd

    Return a copy that receives input on standard input.

    Cmd::stdin_bytes

    fn Cmd::stdin_bytes(self : Cmd, input : Bytes) -> Cmd

    Return a copy that receives arbitrary bytes on standard input.

    Calling stdin or stdin_bytes replaces any previously configured input.

    Cmd::working_directory

    fn Cmd::working_directory(self : Cmd) -> String?

    Return the configured working directory.

    Output

    pub struct Output {
    exit_code : Int
    stage_exit_codes : Array[Int]
    stdout_bytes : Bytes
    stdout : String
    stderr_bytes : Bytes
    stderr : String
    stage_stderr_bytes : Array[Bytes]
    stage_stderr : Array[String]
    } derive(
    Debug
    )

    Captured output and exit status from a command or pipeline.

    For a pipeline, exit_code uses pipefail semantics: it is the rightmost non-zero stage status, or zero when every stage succeeds. stderr is the concatenation of stage_stderr in stage order.

    Output::check

    fn Output::check(self : Output) -> Output raise ProcessError

    Raise CommandFailed when the output is not successful.

    Output::success

    fn Output::success(self : Output) -> Bool

    Whether the command, or every stage of the pipeline, exited with zero.

    Pipeline

    A sequence of commands connected by operating-system pipes.

    An empty pipeline is rejected when execution is requested.

    Pipeline::Pipeline

    #alias(new, deprecated="Use Pipeline::Pipeline instead")
    fn Pipeline::Pipeline(commands : Array[Cmd]) -> Pipeline

    Create a pipeline from commands.

    An empty pipeline is rejected when it is executed.

    Pipeline::commands

    fn Pipeline::commands(self : Pipeline) -> Array[Cmd]

    Return the commands in execution order.

    Pipeline::output

    async fn Pipeline::output(self : Pipeline, timeout_ms? : Int, max_output_bytes? : Int) -> Output

    Execute this pipeline and capture final stdout plus every stage's stderr.

    Stages use real operating-system pipes and run concurrently in one structured task group. timeout_ms, when present, applies to the whole pipeline.

    Pipeline::pipe

    fn Pipeline::pipe(self : Pipeline, command : Cmd) -> Pipeline

    Return a copy with command appended as the next stage.

    Process

    pub enum Process {
    }

    Namespace for executing Cmd and Pipeline values.

    Constructing a command does not execute it. Execution only happens through these methods, which delegate each process to moonbitlang/async/process.

    Process::pipeline

    #deprecated("Use Pipeline::output instead")
    async fn Process::pipeline(pipeline : Pipeline, timeout? : Int, max_output_bytes? : Int) -> Output

    Execute a pipeline and capture its final stdout and every stage's stderr.

    Pipeline uses real operating-system pipes. All stages run concurrently under one structured-concurrency scope. When timeout is present, the whole pipeline is cancelled if it does not finish within that many milliseconds. The direct child for each stage is killed immediately on cancellation; descendants require enforcement by the host's native process sandbox.

    Process::run

    #deprecated("Use Cmd::output instead")
    async fn Process::run(cmd : Cmd, timeout? : Int, max_output_bytes? : Int) -> Output

    Execute one command and capture stdout and stderr.

    Standard input is closed by default. Use Cmd::stdin to supply text. Captured stdout and stderr share the max_output_bytes limit (8 MiB by default). When timeout expires, the direct child is killed immediately; descendants require enforcement by the host's native process sandbox.

    default_max_output_bytes

    let default_max_output_bytes : Int

    Default aggregate limit for captured stdout and stderr: 8 MiB.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io