feat: redesign shell stdlib with structured ShellOutput, exec(), and WASM support#3436
feat: redesign shell stdlib with structured ShellOutput, exec(), and WASM support#3436
Conversation
Replace shell() -> string with shell() -> ShellOutput { stdout, stderr, exit_code }
and add ok() method. Non-zero exit codes are now returned (not thrown). Add
OpErrorKind::Io variant for correct error contract on spawn failures.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add baml.sys.exec(program, args?, options?) for direct process execution without shell interpretation. Add ProcessOptions class with cwd, env, timeout_ms, and stdin fields shared by both exec() and shell(). Extract shared run_process helper for applying options and collecting output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add _output $rust_type handle field storing raw process output bytes. Add stdout_bytes() and stderr_bytes() IO methods for raw binary access, following the http.Response handle class pattern. Update vertex.rs mocks to use generated SysShellOutputHandle type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Unsupported stubs with real JS callback integration for shell() and exec() in WASM. Add WasmExecCallback and WasmShellCallback types, extract callbacks in BamlWasmRuntime::create(), and implement full callback-based execution with Promise handling following WasmHttp pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire up shell and exec JS callbacks in the promptfiddle web worker using just-bash for in-browser command execution. Add BamlVfsAdapter to bridge BamlVfs to just-bash's IFileSystem interface. Add stub callbacks in vscode-webview tests to prevent breakage from new required callback fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add NormalModuleReplacementPlugin to strip "node:" URI prefix and resolve.fallback to stub out zlib/async_hooks/module that leak into the just-bash browser bundle. Fix WasmVfsLike.metadata type to accept string file_type from the WASM VFS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of returning an error when RustData (e.g. ShellOutput._output) cannot be serialized over FFI, emit a placeholder string. This prevents log.debug() from silently dropping events that contain handle classes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add ShellOutput renderer with inline (class name + exit code badge) and expanded (tabbed stdout/stderr with copy buttons) display modes - Parse JSON results from worker instead of passing strings, enabling custom renderer dispatch for function return values - Fix RustData conversion to preserve Arc instead of erroring, so handle classes like ShellOutput can be returned from functions - Fix ValueRenderer to fall through to object rendering for unregistered $baml types instead of showing only the type name - Add webpack polyfills for just-bash node: imports in browser build Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…from bex_events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughShell and exec builtins now return structured ShellOutput (stdout/stderr bytes + exit_code) and accept ProcessOptions (cwd, env, timeout_ms, stdin). Native, WASM, and playground layers are wired to JS callbacks; codegen, conversions, tests, and the UI renderer updated to handle uint8array bytes and new types. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as BAML Runtime
participant NativeSys as NativeSysOps
participant Process as System Process
participant Timer as Timeout Handler
Client->>NativeSys: shell(command, ProcessOptions?)
activate NativeSys
NativeSys->>NativeSys: apply cwd/env, prepare stdin
NativeSys->>Process: spawn (sh -c command) with pipes
alt timeout_ms set
NativeSys->>Timer: start timeout
end
Process-->>NativeSys: stdout/stderr bytes and exit_code
alt timeout fired
Timer->>Process: kill
Process-->>NativeSys: killed / timeout
end
NativeSys-->>Client: ShellOutput { stdout: bytes, stderr: bytes, exit_code }
deactivate NativeSys
sequenceDiagram
participant Baml as BAML Runtime (WASM)
participant WasmSys as WasmSys
participant JS as JS Runtime
participant Bash as just-bash
Baml->>WasmSys: shell(command, optionsJson)
activate WasmSys
WasmSys->>JS: call shell_fn(command, optionsJson) -> Promise
activate JS
JS->>Bash: executeShell(command, opts) (uses VFS adapter)
Bash-->>JS: Promise resolves { stdout, stderr, exit_code, stdout_bytes, stderr_bytes }
JS-->>WasmSys: Promise result
deactivate JS
WasmSys->>WasmSys: unpack into Rust ShellOutput
WasmSys-->>Baml: ShellOutput
deactivate WasmSys
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
baml_language/crates/bridge_ctypes/src/value_encode.rs (1)
360-366:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate the unit test for the new
RustDatafallback.
external_to_baml_value()now returnsStringValue("<native handle>")for unconvertibleRustData, so this assertion is stale andcargo test --libshould fail here.Suggested fix
#[test] fn rust_data_unknown_type_returns_error() { let unknown: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42u32); let value = BexExternalValue::RustData(unknown); let options = HandleTableOptions::for_in_process(); - let result = external_to_baml_value(&value, &options); - assert!(result.is_err()); + let result = external_to_baml_value(&value, &options).unwrap(); + assert!(matches!( + result.value, + Some(BamlValueVariant::StringValue(s)) if s == "<native handle>" + )); }As per coding guidelines, "Always run
cargo test --libif you changed any Rust code".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@baml_language/crates/bridge_ctypes/src/value_encode.rs` around lines 360 - 366, The unit test rust_data_unknown_type_returns_error is stale because external_to_baml_value now converts unconvertible BexExternalValue::RustData into a BamlValue::StringValue("<native handle>") instead of returning Err; update the test (which constructs BexExternalValue::RustData and calls external_to_baml_value with HandleTableOptions::for_in_process) to assert that result is Ok and matches BamlValue::StringValue("<native handle>") (or pattern-match the returned BamlValue and compare the inner string) rather than asserting is_err().typescript2/app-promptfiddle/next.config.js (1)
43-70:⚠️ Potential issue | 🟠 MajorScope the
node:rewrite to client bundles only.This webpack callback runs for both server and client compilations. The
NormalModuleReplacementPluginandresolve.fallbackconfiguration should only apply to the client bundle. On the server side,node:zlib(and other Node core modules) must resolve natively and should not be rewritten or have fallbacks applied. Gate this logic behind!isServerto prevent breaking server-side builds and SSR.Suggested fix
-webpack: (config, { webpack }) => { +webpack: (config, { webpack, isServer }) => { config.resolve = config.resolve || {}; config.resolve.alias = { ...config.resolve.alias, 'pkg-playground': path.resolve(projectDir, '../pkg-playground/src'), 'pkg-proto': path.resolve(projectDir, '../pkg-proto/src') }; // Enable WASM support for bridge_wasm config.experiments = { ...config.experiments, asyncWebAssembly: true, }; - config.plugins.push( - new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => { - resource.request = resource.request.replace(/^node:/, ''); - }) - ); - config.resolve.fallback = { - ...config.resolve.fallback, - zlib: false, - }; + if (!isServer) { + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => { + resource.request = resource.request.replace(/^node:/, ''); + }) + ); + config.resolve.fallback = { + ...config.resolve.fallback, + zlib: false, + }; + } return config; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@typescript2/app-promptfiddle/next.config.js` around lines 43 - 70, The NormalModuleReplacementPlugin and client-only fallbacks are being applied for both server and client builds; guard the client-specific logic inside the webpack function with a check for !isServer so server-side builds aren't affected. In the webpack: (config, { webpack, isServer }) => { ... } callback, wrap the plugin creation (new webpack.NormalModuleReplacementPlugin(/^node:/, ...)) and the config.resolve.fallback assignment (e.g., zlib: false) in a branch executed only when !isServer, leaving alias and experiments untouched so server-side resolution of node: modules remains native.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@baml_language/crates/bridge_wasm/src/wasm_sys.rs`:
- Around line 80-83: The function options_to_js currently maps None to
JsValue::NULL but the JS callback expects optionsJson: string | undefined;
change the None branch in options_to_js (in bridge_wasm::wasm_sys) to return
JsValue::UNDEFINED (or the appropriate undefined constant) instead of
JsValue::NULL so missing options are represented as undefined for JS consumers.
In `@baml_language/crates/sys_llm/src/auth_request/vertex.rs`:
- Around line 211-217: The code currently trusts sys_shell's Ok variant and
reads stdout without verifying the command succeeded; update each usage of
sys_shell (e.g., the call at the if that uses .is_ok_and(|out|
!out.stdout.trim().is_empty())) to explicitly inspect the Ok(ShellOutput) value
and ensure output.exit_code == 0 (or output.exit_code.is_success()) and that
output.stdout.trim().is_empty() is false before treating it as a valid token; do
the same fix for the other occurrences referenced (around the blocks at lines
~243-252, ~281-288, ~358-365) so you only accept stdout when the command exit
code indicates success.
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 849-867: In stdout_bytes and stderr_bytes replace the
panic-causing expect on shelloutput._output.downcast_ref::<(Vec<u8>, Vec<u8>)>()
with a safe check: if downcast_ref returns Some(pair) return
SysOpOutput::ok(pair.0.clone()) / SysOpOutput::ok(pair.1.clone()), otherwise
return a SysOpOutput::err constructed with the appropriate OpErrorKind (e.g.,
OpErrorKind::TypeMismatch or InvalidHandle) and an explanatory message; this
avoids panics from expect and uses the OpErrorKind-based error pathway for bad
handle types.
- Around line 730-768: The timeout branch currently spawns the child process
into the wait task (task_child) and returns a Timeout error without killing the
child; change ownership so you can kill the process on timeout by wrapping the
Child in a shared async mutex (e.g., Arc<tokio::sync::Mutex<Child>>) instead of
moving it directly into the task, clone that Arc into the spawned wait task
which will call lock().await.wait_with_output().await, and on timeout acquire
the mutex and call kill() on the Child (and then await the wait task or abort
it) before returning OpErrorKind::Timeout; update symbols: replace uses of child
/ task_child / wait_task with the Arc<Mutex<Child>> pattern and ensure you
handle and propagate kill() errors into the OpErrorKind where appropriate.
In `@typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts`:
- Around line 210-214: The command construction interpolates the unescaped
program into commandLine (see quotedArgs and commandLine) and is passed to
bash.exec(), allowing shell injection via the program parameter; fix by calling
a safer exec variant that accepts the program and args separately (e.g., use
child_process.execFile/child_process.spawn or the equivalent bash.exec(command,
args) form) or by validating/whitelisting the program binary path before
execution, and stop building a single interpolated shell string — pass program
as the executable and the args array (or validate/escape program) so bash.exec()
cannot interpret injected shell metacharacters.
In `@typescript2/app-promptfiddle/src/playground/baml-vfs-adapter.ts`:
- Around line 149-152: resolvePath currently concatenates strings and leaves "."
and ".." segments unnormalized, causing mismatches with the VFS keys; update
resolvePath (in BamlVfsAdapter) to perform POSIX-style normalization: if path
starts with "/" treat it as absolute and normalize; otherwise join base
(ensuring it has no trailing slash) with path then split by "/", iterate
segments skipping "" and ".", pop last segment for ".." unless at root, and
rejoin with single "/" (preserve leading "/" for absolute results); ensure
result canonicalizes multiple slashes and avoids producing "." segments.
In `@typescript2/pkg-playground/src/ExecutionPanel.tsx`:
- Around line 495-499: The message handler currently deletes the entry from
pendingCallsRef and then calls JSON.parse(data.result), which means a parse
exception will leave the pending promise neither resolved nor rejected; modify
the handler around the block that looks up pendingCallsRef.current.get(data.id)
(and uses pending.resolve) to parse data.result inside a try/catch, and in the
catch call pending.reject(error) (or reject with a descriptive Error) before
deleting the entry (or ensure deletion happens in finally) so every pending call
is either resolved or rejected even if JSON.parse throws.
In `@typescript2/pkg-playground/src/renderers/ShellOutput.tsx`:
- Around line 74-97: The inline branch of ShellOutputRenderer currently only
shows exit_code and never uses the computed summary or the expanded state;
update the inline (displayMode === 'inline' || 'inline-hint') return to render
the summary (variable summary) next to the exit_code and add a click/toggle
handler using expanded/setExpanded to expand into the full stdout/stderr view
(reuse the same expanded UI used for non-inline modes) so users can at minimum
see the summary and optionally expand to inspect stdout/stderr.
---
Outside diff comments:
In `@baml_language/crates/bridge_ctypes/src/value_encode.rs`:
- Around line 360-366: The unit test rust_data_unknown_type_returns_error is
stale because external_to_baml_value now converts unconvertible
BexExternalValue::RustData into a BamlValue::StringValue("<native handle>")
instead of returning Err; update the test (which constructs
BexExternalValue::RustData and calls external_to_baml_value with
HandleTableOptions::for_in_process) to assert that result is Ok and matches
BamlValue::StringValue("<native handle>") (or pattern-match the returned
BamlValue and compare the inner string) rather than asserting is_err().
In `@typescript2/app-promptfiddle/next.config.js`:
- Around line 43-70: The NormalModuleReplacementPlugin and client-only fallbacks
are being applied for both server and client builds; guard the client-specific
logic inside the webpack function with a check for !isServer so server-side
builds aren't affected. In the webpack: (config, { webpack, isServer }) => { ...
} callback, wrap the plugin creation (new
webpack.NormalModuleReplacementPlugin(/^node:/, ...)) and the
config.resolve.fallback assignment (e.g., zlib: false) in a branch executed only
when !isServer, leaving alias and experiments untouched so server-side
resolution of node: modules remains native.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 020a772d-44ba-4b47-8f5c-00a156f2b4c5
⛔ Files ignored due to path filters (15)
baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__01_lexer__shell_ops.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__02_parser__shell_ops.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__10_formatter__shell_ops.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snapis excluded by!**/*.snaptypescript2/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (21)
baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.bamlbaml_language/crates/baml_tests/projects/builtin_io/shell_ops.bamlbaml_language/crates/baml_tests/tests/shell.rsbaml_language/crates/bex_engine/src/conversion.rsbaml_language/crates/bex_events/src/serialize.rsbaml_language/crates/bridge_ctypes/src/value_encode.rsbaml_language/crates/bridge_wasm/src/lib.rsbaml_language/crates/bridge_wasm/src/wasm_sys.rsbaml_language/crates/sys_llm/src/auth_request/vertex.rsbaml_language/crates/sys_native/src/io_impls.rsbaml_language/crates/sys_ops/src/lib.rsbaml_language/crates/sys_types/src/lib.rstypescript2/app-promptfiddle/next.config.jstypescript2/app-promptfiddle/package.jsontypescript2/app-promptfiddle/src/playground/baml-lsp-worker.tstypescript2/app-promptfiddle/src/playground/baml-vfs-adapter.tstypescript2/app-vscode-webview/src/bridge_wasm.test.tstypescript2/pkg-playground/src/ExecutionPanel.tsxtypescript2/pkg-playground/src/ValueRenderer.tsxtypescript2/pkg-playground/src/renderers/ShellOutput.tsxtypescript2/pkg-playground/src/renderers/registerBuiltins.ts
💤 Files with no reviewable changes (1)
- baml_language/crates/bex_events/src/serialize.rs
…y.to_string()
Replace the handle class pattern on ShellOutput with plain uint8array
fields. Subprocess output is fundamentally bytes; text is a decode step.
This eliminates the _output $rust_type field, stdout_bytes()/stderr_bytes()
IO methods, IoClassSysShellOutput trait, and all associated wiring — ~60
lines of boilerplate across 4 crates.
- Add uint8array.to_string() (lossy UTF-8 decode via from_utf8_lossy)
and string.to_bytes() (UTF-8 encode) as $rust_function builtins
- Change ShellOutput.stdout/stderr from string to uint8array
- Simplify run_process in sys_native: move bytes directly, no Arc/clone
- Remove IoClassSysShellOutput impls from sys_native, sys_ops, bridge_wasm
- Remove wiring closures from IoSysOpsBuilder::with_sys_instance
- Simplify unpack_shell_result in WASM bridge to read only byte fields
- Fix codegen into_owned_expr to handle BamlType::Uint8Array fields
- Fix vertex.rs gcloud auth callsites for Vec<u8> stdout
- Add uint8arrayValue case to pkg-proto decode.ts (was silently null)
- Add Uint8Array to BamlJsValue type union
- Add JSON replacer in worker that encodes Uint8Array as tagged base64
{ $baml: { type: "$bytes" }, base64: "..." } for safe serialization
- Update ShellOutput renderer to decode tagged base64 bytes for display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (4)
baml_language/crates/sys_llm/src/auth_request/vertex.rs (1)
211-217:⚠️ Potential issue | 🟠 Major | ⚡ Quick winCheck
exit_codebefore trustinggcloudstdout.
ShellOutputnow returns non-zero exits as values, so these fallbacks can still accept stale stdout from failed commands. Requireoutput.exit_code == 0before selecting the CLI path or extractingtoken/project_id.🔧 Suggested fix
- .await - .is_ok_and(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty()) + .await + .is_ok_and(|out| out.exit_code == 0 && !String::from_utf8_lossy(&out.stdout).trim().is_empty())Apply the same guard in the later
token_from_credentialsandproject_id_from_credentialsgcloud branches as well.Also applies to: 243-258, 280-291, 355-368
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@baml_language/crates/sys_llm/src/auth_request/vertex.rs` around lines 211 - 217, The current gcloud CLI branches use sys_shell(...) and only check stdout content, which can accept stale output when the command exited non‑zero; update the conditional logic to also require output.exit_code == 0 before trusting the CLI path and extracting values. Concretely, in the sys_shell invocation checks (the one shown and the similar branches in token_from_credentials and project_id_from_credentials) inspect the returned ShellOutput and only proceed if output.exit_code == 0 && !String::from_utf8_lossy(&output.stdout).trim().is_empty(); apply the same guard where you currently parse token/project_id from gcloud output so non‑zero exits don’t allow stale stdout to be used.typescript2/pkg-playground/src/renderers/ShellOutput.tsx (1)
112-121:⚠️ Potential issue | 🟠 Major | ⚡ Quick winInline mode still drops the actual shell output.
summaryis computed andexpandedstate is tracked, but the inline branch only renders the exit-code badge. In event-log / inline-hint contexts that makesstdoutandstderrimpossible to inspect.Minimal fix
if (displayMode === 'inline' || displayMode === 'inline-hint') { return ( <span className="font-vsc-mono text-xs inline-flex items-center gap-1.5"> <span className="text-vsc-text-muted">baml.sys.ShellOutput</span> <span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${ok ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}> exit_code: {code} </span> + <span className="text-vsc-text">{summary}</span> </span> ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@typescript2/pkg-playground/src/renderers/ShellOutput.tsx` around lines 112 - 121, The inline branch in ShellOutput (checking displayMode === 'inline' || 'inline-hint') only renders the exit_code badge and drops the actual shell output; update that branch to also render the computed summary and make it expand/collapse using the existing expanded state and summary variable so stdout/stderr can be inspected in event-log contexts—locate the inline branch in the ShellOutput component and add a clickable summary (or truncated text) that toggles expanded, showing full stdout/stderr when expanded while preserving the exit_code badge and ok/code styling.baml_language/crates/sys_native/src/io_impls.rs (1)
748-767:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winTimeouts still return before the child process is torn down.
The timeout branch reports
OpErrorKind::Timeout, but it never kills the spawned child or aborts the waiter first. A timed-out command can keep running in the background after the API has already returned failure.Suggested fix
- let mut child = cmd.spawn().map_err(|e| OpErrorKind::Io { + cmd.kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| OpErrorKind::Io { message: format!("Failed to spawn '{label}': {e}"), })?; @@ - let task_child = child; - let wait_task = tokio::spawn(async move { task_child.wait_with_output().await }); - match tokio::time::timeout(duration, wait_task).await { + let task_child = child; + let mut wait_task = tokio::spawn(async move { task_child.wait_with_output().await }); + match tokio::time::timeout(duration, &mut wait_task).await { @@ Err(_elapsed) => { + wait_task.abort(); return Err(OpErrorKind::Timeout { message: format!("Command '{label}' timed out after {ms}ms"), duration, }); }#!/bin/bash # Verify the timeout branch does not currently kill or abort the child/waiter. rg -n -C4 'kill_on_drop|wait_with_output|tokio::time::timeout|wait_task\.abort|OpErrorKind::Timeout' baml_language/crates/sys_native/src/io_impls.rs🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@baml_language/crates/sys_native/src/io_impls.rs` around lines 748 - 767, The timeout branch for running commands returns OpErrorKind::Timeout without stopping the child or aborting the waiter — update the timeout arm where you call tokio::time::timeout(duration, wait_task) so that on Err(_elapsed) you first attempt to kill the child process (use the spawned child's kill/kill_on_drop method or send a kill to the process represented by variable child/task_child) and then abort the wait_task (call wait_task.abort()), await/handle the abort/join result, and include any kill/join errors in the final OpErrorKind::Timeout message; ensure you reference the existing variables child/task_child, wait_task, wait_with_output, and OpErrorKind::Timeout and handle potential errors from kill and abort before returning the timeout error.typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts (1)
218-223:⚠️ Potential issue | 🔴 Critical | ⚡ Quick win
exec()still interpolates an unescapedprograminto a shell command.Line 223 builds a command string and passes it to
bash.exec(), but onlyargsare quoted. Ifprogramcontains shell metacharacters, the browserexec()path is still injectable and no longer behaves like a direct argv-based exec.Suggested fix
+const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; + const quotedArgs = (args ?? []) - .map((a) => "'" + a.replace(/'/g, "'\\''") + "'") + .map(shellQuote) .join(" "); -const commandLine = quotedArgs ? `${program} ${quotedArgs}` : program; +const quotedProgram = shellQuote(program); +const commandLine = quotedArgs ? `${quotedProgram} ${quotedArgs}` : quotedProgram;#!/bin/bash # Verify that executeExec still interpolates the raw program into bash.exec(). rg -n -C3 'const quotedArgs|const commandLine|bash\.exec\(commandLine' typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts` around lines 218 - 223, The commandLine construction interpolates the raw program into a shell string (variables quoted in quotedArgs but not program), making bash.exec(commandLine) vulnerable to shell injection; update the code that builds commandLine in baml-lsp-worker.ts to either 1) escape/quote program the same way as args (apply the same single-quote + replace(/'/g, "'\\''") logic to program before concatenation) or 2) better, change the call site (bash.exec) to use an argv-style/array exec API that accepts program and args separately (avoid creating a single shell string) so that program is not shell-interpolated; target symbols: quotedArgs, commandLine, program, and bash.exec() when applying the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 721-724: The current branch in io_impls.rs clears the entire
inherited environment when ProcessOptions.env is Some by calling cmd.env_clear()
before cmd.envs(...), which removes PATH/HOME and diverges from the WASM
behavior (replaceEnv: false); change this to merge the supplied env into the
inherited environment instead of clearing it—i.e., remove the cmd.env_clear()
call (or only call env_clear() when an explicit replace flag is set) and keep
cmd.envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) so opts.env augments
rather than replaces the process environment in exec()/shell() flows.
---
Duplicate comments:
In `@baml_language/crates/sys_llm/src/auth_request/vertex.rs`:
- Around line 211-217: The current gcloud CLI branches use sys_shell(...) and
only check stdout content, which can accept stale output when the command exited
non‑zero; update the conditional logic to also require output.exit_code == 0
before trusting the CLI path and extracting values. Concretely, in the sys_shell
invocation checks (the one shown and the similar branches in
token_from_credentials and project_id_from_credentials) inspect the returned
ShellOutput and only proceed if output.exit_code == 0 &&
!String::from_utf8_lossy(&output.stdout).trim().is_empty(); apply the same guard
where you currently parse token/project_id from gcloud output so non‑zero exits
don’t allow stale stdout to be used.
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 748-767: The timeout branch for running commands returns
OpErrorKind::Timeout without stopping the child or aborting the waiter — update
the timeout arm where you call tokio::time::timeout(duration, wait_task) so that
on Err(_elapsed) you first attempt to kill the child process (use the spawned
child's kill/kill_on_drop method or send a kill to the process represented by
variable child/task_child) and then abort the wait_task (call
wait_task.abort()), await/handle the abort/join result, and include any
kill/join errors in the final OpErrorKind::Timeout message; ensure you reference
the existing variables child/task_child, wait_task, wait_with_output, and
OpErrorKind::Timeout and handle potential errors from kill and abort before
returning the timeout error.
In `@typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts`:
- Around line 218-223: The commandLine construction interpolates the raw program
into a shell string (variables quoted in quotedArgs but not program), making
bash.exec(commandLine) vulnerable to shell injection; update the code that
builds commandLine in baml-lsp-worker.ts to either 1) escape/quote program the
same way as args (apply the same single-quote + replace(/'/g, "'\\''") logic to
program before concatenation) or 2) better, change the call site (bash.exec) to
use an argv-style/array exec API that accepts program and args separately (avoid
creating a single shell string) so that program is not shell-interpolated;
target symbols: quotedArgs, commandLine, program, and bash.exec() when applying
the fix.
In `@typescript2/pkg-playground/src/renderers/ShellOutput.tsx`:
- Around line 112-121: The inline branch in ShellOutput (checking displayMode
=== 'inline' || 'inline-hint') only renders the exit_code badge and drops the
actual shell output; update that branch to also render the computed summary and
make it expand/collapse using the existing expanded state and summary variable
so stdout/stderr can be inspected in event-log contexts—locate the inline branch
in the ShellOutput component and add a clickable summary (or truncated text)
that toggles expanded, showing full stdout/stderr when expanded while preserving
the exit_code badge and ok/code styling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 8ccc87f2-e19c-4c52-8d9b-37ab6e3aa883
⛔ Files ignored due to path filters (8)
baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__05_diagnostics.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snapis excluded by!**/*.snap
📒 Files selected for processing (15)
baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.bamlbaml_language/crates/baml_builtins2/baml_std/baml/string.bamlbaml_language/crates/baml_builtins2/baml_std/baml/uint8array.bamlbaml_language/crates/baml_builtins2_codegen/src/codegen_io.rsbaml_language/crates/baml_tests/tests/shell.rsbaml_language/crates/bex_vm/src/package_baml/string.rsbaml_language/crates/bex_vm/src/package_baml/uint8array.rsbaml_language/crates/bridge_wasm/src/wasm_sys.rsbaml_language/crates/sys_llm/src/auth_request/vertex.rsbaml_language/crates/sys_native/src/io_impls.rsbaml_language/crates/sys_ops/src/lib.rstypescript2/app-promptfiddle/src/playground/baml-lsp-worker.tstypescript2/pkg-playground/src/renderers/ShellOutput.tsxtypescript2/pkg-proto/src/decode.tstypescript2/pkg-proto/src/types.ts
✅ Files skipped from review due to trivial changes (1)
- typescript2/pkg-proto/src/types.ts
The test_vm_param_matches_vm_usage test used a global string search for `fn to_string(vm: &BexVm,` which false-matched across classes when uint8array.to_string (VmUsage::None) and errors.StackTrace.to_string (VmUsage::Ref) both exist. Include full param list in the search pattern to scope each check to its specific builtin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…xecutionPanel - Add kill_on_drop(true) to spawned child processes so timed-out commands are terminated when the wait task is dropped - Wrap JSON.parse(data.result) in try/catch so parse failures reject the pending call instead of leaving it hanging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
baml_language/crates/sys_native/src/io_impls.rs (1)
751-768:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winTimeout still leaves the child running in the background.
Dropping the
JoinHandlecreated on Line 753 only detaches the task; it does not cancel it. That meanstask_child.wait_with_output()keeps owning theChild, sokill_on_drop(true)never fires in the timeout branch and the process can continue running after this function has already returnedOpErrorKind::Timeout. Tokio documents both behaviors explicitly. (docs.rs)💡 Suggested fix
- let task_child = child; - let wait_task = tokio::spawn(async move { task_child.wait_with_output().await }); - match tokio::time::timeout(duration, wait_task).await { + let task_child = child; + let mut wait_task = tokio::spawn(async move { task_child.wait_with_output().await }); + match tokio::time::timeout(duration, &mut wait_task).await { Ok(Ok(result)) => result.map_err(|e| OpErrorKind::Io { message: format!("Failed to wait on '{label}': {e}"), })?, Ok(Err(join_err)) => { return Err(OpErrorKind::Io { message: format!("Task join error waiting on '{label}': {join_err}"), }); } Err(_elapsed) => { + wait_task.abort(); return Err(OpErrorKind::Timeout { message: format!("Command '{label}' timed out after {ms}ms"), duration, }); } }In Tokio 1.x, does dropping a `tokio::task::JoinHandle` cancel the task or detach it? And does `tokio::process::Command::kill_on_drop(true)` only kill the process when the `Child` handle itself is dropped?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@baml_language/crates/sys_native/src/io_impls.rs` around lines 751 - 768, The timeout branch currently returns without cancelling the spawned task that owns the Child, so the child process keeps running; fix by aborting the spawn before returning so the task drops its Child (triggering kill_on_drop(true)) and optionally await the join to observe errors. Concretely, in the timeout arm where wait_task is the JoinHandle of task_child.wait_with_output(), call wait_task.abort() (and then optionally let _ = wait_task.await to drive drop/cleanup) before returning the OpErrorKind::Timeout; reference symbols: task_child, wait_task, wait_with_output, and kill_on_drop(true).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 737-748: The stdin write currently happens outside the timeout and
ignores errors; update the execution flow (the block that uses child, options,
and timeout_ms) so the stdin write (using child.stdin.take() and
stdin_pipe.write_all) is performed inside the same async "run" future that is
wrapped by the timeout, and propagate any write errors (map them to
OpErrorKind::Io with a clear message referencing label) instead of discarding
them; then await child.wait_with_output() inside that same future and map its
errors similarly so both stdin writes and process waiting are covered by the
timeout and proper error handling.
In `@typescript2/pkg-playground/src/ExecutionPanel.tsx`:
- Around line 498-503: The current try/catch in ExecutionPanel that calls
JSON.parse on data.result unconditionally causes already-decoded objects from
WebSocketRuntimePort to throw; change the handling so that in the
callFunctionResult path you detect the type of data.result (e.g., if typeof
data.result === "string") and only call JSON.parse for string payloads,
otherwise resolve with data.result directly; ensure pending.resolve is passed
the decoded object and pending.reject still wraps non-Error throwables as before
so the error behavior remains consistent.
---
Duplicate comments:
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 751-768: The timeout branch currently returns without cancelling
the spawned task that owns the Child, so the child process keeps running; fix by
aborting the spawn before returning so the task drops its Child (triggering
kill_on_drop(true)) and optionally await the join to observe errors. Concretely,
in the timeout arm where wait_task is the JoinHandle of
task_child.wait_with_output(), call wait_task.abort() (and then optionally let _
= wait_task.await to drive drop/cleanup) before returning the
OpErrorKind::Timeout; reference symbols: task_child, wait_task,
wait_with_output, and kill_on_drop(true).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: f9db9142-f5be-44a2-9a8f-facff2a04c7f
📒 Files selected for processing (2)
baml_language/crates/sys_native/src/io_impls.rstypescript2/pkg-playground/src/ExecutionPanel.tsx
Dropping a JoinHandle detaches the task — it doesn't abort it. The spawned task would keep polling wait_with_output() in the background, so task_child is never dropped and kill_on_drop never fires. Fix by taking &mut wait_task in the timeout (so the handle isn't consumed) and calling wait_task.abort() on timeout to cancel the future, drop task_child, and trigger SIGKILL via kill_on_drop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit bundles three changes that were developed together while
debugging end-to-end function execution in the playground.
## 1. Unify callFunctionResult serialization across worker + websocket
Previously the WASM worker JSON-stringified the decoded result tree
(losing Uint8Array as native bytes, bigint precision, and shaping
handle descriptors differently from the WebSocket path). The WebSocket
path posted decoded objects directly. ExecutionPanel had a runtime
typeof/JSON.parse guard to bridge the two formats.
Both paths now deliver a structurally identical
BamlJsValue<PlainHandleDescriptor>:
- Worker's wrapHandle returns POJOs ({ handle_key, handle_type,
type_name }) instead of live BamlHandle objects, and posts the
decoded value directly via postMessage (structured clone handles
Uint8Array, bigint, plain objects natively).
- jsonReplacer + JSON.stringify removed from worker result paths.
- ExecutionPanel normalization guard removed —
pending.resolve(data.result) directly.
- worker-protocol.ts: callFunctionResult.result typed as
BamlJsValue<PlainHandleDescriptor>.
- format-value.ts: \$handle case now reads value.handle.handle_key
(was reading the broken value.handle_key on the outer wrapper).
New types/tests:
- pkg-proto: PlainHandleDescriptor type added and exported.
- pkg-proto: 3 new decode tests (uint8array, media url, prompt ast
simple) + 5 structuredClone round-trip tests covering primitives,
Uint8Array, classes with nested fields, handles with bigint keys,
and complex nested values.
- pkg-playground: vitest infrastructure added with 7 format-value
tests covering primitives, arrays, classes, handles (with
PlainHandleDescriptor), media, prompt_ast, and plain maps.
## 2. Fix LSP crash: "no reactor running" panic in run_async_in_background
The native LSP server creates a tokio Runtime but runs the stdin event
loop on the main thread outside the runtime context. When dispatch
eventually reached wasm_helpers::run_async_in_background, tokio::spawn
panicked because no runtime was associated with the current thread.
Replaced the free function with a concrete BackgroundSpawner type that
makes the runtime dependency explicit:
- Native: wraps tokio::runtime::Handle, uses handle.spawn (Send + 'static).
- WASM: zero-size sentinel, uses spawn_local ('static only).
- Two constructors: new() captures Handle::current() (works on both
targets; bridge_wasm uses this since it always runs inside a runtime
on native via the LSP server, and is a no-op on WASM); with_handle()
is native-only for callers that have an explicit Handle (used by
baml_lsp_server which constructs the spawner before entering the
runtime).
Plumbed BackgroundSpawner through BexMulitProject::new and new_lsp,
re-exported from bex_project, and constructed at both call sites.
## 3. LSP log level: silence salsa debug noise by default
Changed baml_lsp_server's default tracing filter from "debug" to
"info,salsa=warn". Users can still override with RUST_LOG.
## Misc
- .vscode/launch.json: v2 extension launch config now sets
BAML_CLI_PATH, BAML_PLAYGROUND_DEV_PORT=4000, and RUST_BACKTRACE=1.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
The shell function now takes (command, options?) and returns ShellOutput instead of string. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unknown RustData types now return a "<native handle>" fallback string instead of an error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
shell() now detects the platform shell instead of hardcoding `sh -c`: - macOS: user default → zsh → bash → /bin/sh - Linux: user default → bash → zsh → /bin/sh - Windows: pwsh → powershell → cmd.exe Shell tests updated with cfg(windows) variants for exec() tests that use Unix-only programs (pwd, false, printf, cat, sleep). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bare `cd` only prints the cwd in cmd.exe; in PowerShell it's an alias for Set-Location which jumps to $HOME and prints nothing. Delegate to cmd.exe via `cmd /c cd` so the test works regardless of which shell default_shell() resolves to, and make the assertion case-insensitive since Windows path casing is not guaranteed. Made-with: Cursor
Redesign stdlib shell | Artifacts | Task
What problems was I solving
baml.sys.shell()returned bare stdout as a string, discarding stderr and the exit code. Non-zero exit codes threw uncatchable errors, making it impossible to handle expected failures likegrepreturning 1. The error contract was wrong — it declaredthrows root.errors.Iobut usedOpErrorKind::Other(categoryDevOther), causing a contract violation on every error. There was no direct-exec mode (everything went throughsh -c), no way to set cwd/env/timeout/stdin, and no WASM/playground support for shell operations.After this ships:
ShellOutput { stdout, stderr, exit_code }where stdout/stderr areuint8array(bytes-first, like Bun/Python subprocess APIs)uint8array.to_string()andstring.to_bytes()added as general-purpose UTF-8 conversion builtinsok()exec()provides direct process execution without shell interpretationProcessOptionsenables cwd, env, timeout, and stdin configurationWhat user-facing changes did I ship
ShellOutputpure data class withuint8arraystdout/stderr,ProcessOptionsclass,exec()function;shell()now returnsShellOutputand accepts optionalProcessOptionsto_string()method (lossy UTF-8 decode)to_bytes()method (UTF-8 encode)run_processhelper supporting cwd/env/stdin/timeout, returning bytes directly (no Arc/clone)OpErrorKind::Iovariant for correct error contractBamlJsValuedirectly via structured clone instead of JSON round-trip)How I implemented it
Phase 1: Structured ShellOutput + Error Contract Fix
The minimum vertical slice. Declared
ShellOutputas a class withstdout,stderr,exit_codefields and a pure-BAMLok()method in sys.baml. Changedshell()return type fromstringtoShellOutput. AddedOpErrorKind::Io { message }for correct error contract. Flipped behavior so non-zero exit codes return inShellOutputinstead of throwing.Phase 2: exec() + ProcessOptions
Added
exec(program, args?, options?)for direct process execution andProcessOptions { cwd, env, timeout_ms, stdin }shared by both functions. Extracted arun_processasync helper that applies options and collects output, with proper stdin piping, timeout viatokio::time::timeout, and process cleanup.Phase 3: WASM Bridge
Replaced
Unsupportedstubs with full JS callback integration in wasm_sys.rs. AddedWasmExecCallbackandWasmShellCallbacktypes,unpack_shell_result()for JS→Rust conversion, andoptions_to_js()for Rust→JS serialization of ProcessOptions.Phase 4: TypeScript Playground Integration
Installed
just-bashand wired up browser-side shell execution in the promptfiddle web worker. CreatedBamlVfsAdapterto bridge the existing BamlVfs to just-bash's filesystem interface.Phase 5: Bytes-only simplification +
uint8array.to_string()Subprocess output is fundamentally bytes — text is a decode step. Replaced the handle class pattern (which had
_output $rust_type+stdout_bytes()/stderr_bytes()IO methods) with plainuint8arrayfields. This eliminated theIoClassSysShellOutputtrait, all associated impl blocks across 3 crates, the wiring closures, and theArc<(Vec<u8>, Vec<u8>)>allocation — ~60 lines of boilerplate removed.Added
uint8array.to_string()(lossy UTF-8 decode) andstring.to_bytes()(UTF-8 encode) as general-purpose$rust_functionbuiltins, composable beyond just shell output.Phase 6: Unified callFunctionResult serialization + LSP fixes
Discovered during end-to-end playground testing that the WASM worker and WebSocket paths produced different
callFunctionResultpayload shapes — worker JSON-stringified the result (losingUint8Arrayas native bytes andbigintprecision), while WebSocket posted decoded objects directly. Unified both to postBamlJsValue<PlainHandleDescriptor>directly via structured clone:wrapHandlenow returns POJOs instead of liveBamlHandleobjects; removedjsonReplacer+JSON.stringifyfrom result pathstypeof/JSON.parsenormalization guard;pending.resolve(data.result)directlycallFunctionResult.resulttyped asBamlJsValue<PlainHandleDescriptor>$handlecase to readvalue.handle.handle_keyPlainHandleDescriptortypeAlso fixed LSP runtime panics discovered during testing:
run_async_in_backgroundfree function withBackgroundSpawnerthat makes the tokio runtime dependency explicit (native:Handle::spawn, WASM:spawn_local)info,salsa=warnto silence debug noiseSupporting Changes
RustDataconversion non-fatal so handle classes survive the engine pipelineRustDataover FFI serializes as"<native handle>"instead of erroringShellOutputAPI (.stdoutis nowVec<u8>, decoded viafrom_utf8_lossy)to_string)into_owned_exprto handleBamlType::Uint8Arrayfieldsnode:importsuint8arrayValuecase (was silently null)Deviations from the plan
Implemented as planned
ok()as pure-BAML methodDeviations/surprises
ShellOutputto a handle class with_output $rust_type+stdout_bytes()/stderr_bytes()IO methods (followinghttp.Responsepattern). This was implemented, then replaced with a simpler bytes-only design —stdout/stderrareuint8arrayfields directly, no handle class needed. Thehttp.Responsepattern exists because HTTP bodies may be streaming/lazy; shell output is always fully captured, so the laziness is unnecessary overhead.uint8array.to_string()andstring.to_bytes()added as general-purpose builtins (not in plan) — prerequisite for the bytes-only approachtokio::spawn+ join handle instead of directchild.wait_with_output()wrapping — works around ownership issues with killing after moveexec()in browser: Routes through just-bash shell interpreter rather than true direct-exec, unavoidable given just-bash's API. Native path correctly usesCommand::new(program)directlyUint8Arrayandbigint); now both worker and WebSocket paths post decodedBamlJsValue<PlainHandleDescriptor>directly via structured cloneAdditions not in plan
uint8array.to_string()/string.to_bytes()— General-purpose UTF-8 conversion builtinsShellOutput.tsxcustom renderer — Playground result renderer with tabbed stdout/stderrPlainHandleDescriptortype, structuredClone round-trip tests, and format-value testsBackgroundSpawner— Fixed LSP "no reactor running" panic by making tokio runtime dependency explicit forrun_async_in_backgroundconversion.rsfix —RustDataconversion made non-fatalvalue_encode.rsfix —RustDataserializes as"<native handle>"over FFIcodegen.rstest fix — Disambiguateto_stringmethod across classescodegen_io.rsfix —into_owned_exprforUint8Arrayfieldsdecode.ts/types.ts—uint8arrayValuesupport in pkg-protovertex.rscall site update — Required but not listed in plannext.config.jswebpack polyfills — Required for just-bashnode:importsinfo,salsa=warnto silence debug noiseItems planned but not implemented
stdout_bytes()/stderr_bytes()IO methods — Superseded by making stdout/stderruint8arrayfields directly. The handle class pattern was unnecessary for fully-captured output._output $rust_typefield — Eliminated along with the IO methodsIoClassSysShellOutputtrait + impls — No longer neededHow to verify it
Setup
Automated Tests
Manual Testing
baml_cli run -e 'baml.sys.shell("echo hello").stdout.to_string()'— returns"hello\n"baml_cli run -e 'baml.sys.shell("exit 1").exit_code'— returns1, does NOT throwbaml_cli run -e 'baml.sys.shell("echo hi").ok()'— returnstruebaml_cli run -e 'baml.sys.exec("printf", ["%s", "hello"]).stdout.to_string()'— returns"hello"baml_cli run -e 'baml.sys.exec("cat", null, baml.sys.ProcessOptions { stdin: "test" }).stdout.to_string()'— returns"test"baml_cli run -e '"hello".to_bytes().to_string()'— roundtrip returns"hello"baml.sys.shell("echo hello")in browser — verify ShellOutput renderer displays decoded bytesDescription for the changelog
Redesign shell stdlib:
shell()and newexec()returnShellOutput { stdout: uint8array, stderr: uint8array, exit_code: int }withok()method. Subprocess output is bytes-first (use.to_string()for text). Adduint8array.to_string()andstring.to_bytes()builtins. AddProcessOptionsfor cwd/env/timeout/stdin. Non-zero exits no longer throw. WASM/playground support via just-bash.