Skip to content

feat: redesign shell stdlib with structured ShellOutput, exec(), and WASM support#3436

Merged
hellovai merged 21 commits intocanaryfrom
hellovai/redesign-stdlib-shell
Apr 30, 2026
Merged

feat: redesign shell stdlib with structured ShellOutput, exec(), and WASM support#3436
hellovai merged 21 commits intocanaryfrom
hellovai/redesign-stdlib-shell

Conversation

@hellovai
Copy link
Copy Markdown
Contributor

@hellovai hellovai commented Apr 30, 2026

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 like grep returning 1. The error contract was wrong — it declared throws root.errors.Io but used OpErrorKind::Other (category DevOther), causing a contract violation on every error. There was no direct-exec mode (everything went through sh -c), no way to set cwd/env/timeout/stdin, and no WASM/playground support for shell operations.

After this ships:

  • Users get structured ShellOutput { stdout, stderr, exit_code } where stdout/stderr are uint8array (bytes-first, like Bun/Python subprocess APIs)
  • uint8array.to_string() and string.to_bytes() added as general-purpose UTF-8 conversion builtins
  • Non-zero exit codes are returned, not thrown — users can handle failures gracefully via ok()
  • exec() provides direct process execution without shell interpretation
  • ProcessOptions enables cwd, env, timeout, and stdin configuration
  • Shell commands work in the browser playground via just-bash

What user-facing changes did I ship

  • sys.bamlShellOutput pure data class with uint8array stdout/stderr, ProcessOptions class, exec() function; shell() now returns ShellOutput and accepts optional ProcessOptions
  • uint8array.baml — New to_string() method (lossy UTF-8 decode)
  • string.baml — New to_bytes() method (UTF-8 encode)
  • io_impls.rs — Native implementation with shared run_process helper supporting cwd/env/stdin/timeout, returning bytes directly (no Arc/clone)
  • sys_types/src/lib.rs — New OpErrorKind::Io variant for correct error contract
  • wasm_sys.rs — Full WASM implementation via JS callbacks
  • bridge_wasm/src/lib.rs — TypeScript callback type declarations and wiring
  • baml-lsp-worker.ts — Playground shell/exec callbacks using just-bash; unified callFunctionResult serialization (posts decoded BamlJsValue directly via structured clone instead of JSON round-trip)
  • baml-vfs-adapter.ts — VFS adapter bridging BamlVfs to just-bash's IFileSystem
  • ShellOutput.tsx — Custom playground renderer that decodes bytes for display
  • shell.rs — Comprehensive test suite (15+ tests covering shell, exec, options, bytes, to_string, ok)

How I implemented it

Phase 1: Structured ShellOutput + Error Contract Fix

The minimum vertical slice. Declared ShellOutput as a class with stdout, stderr, exit_code fields and a pure-BAML ok() method in sys.baml. Changed shell() return type from string to ShellOutput. Added OpErrorKind::Io { message } for correct error contract. Flipped behavior so non-zero exit codes return in ShellOutput instead of throwing.

Phase 2: exec() + ProcessOptions

Added exec(program, args?, options?) for direct process execution and ProcessOptions { cwd, env, timeout_ms, stdin } shared by both functions. Extracted a run_process async helper that applies options and collects output, with proper stdin piping, timeout via tokio::time::timeout, and process cleanup.

Phase 3: WASM Bridge

Replaced Unsupported stubs with full JS callback integration in wasm_sys.rs. Added WasmExecCallback and WasmShellCallback types, unpack_shell_result() for JS→Rust conversion, and options_to_js() for Rust→JS serialization of ProcessOptions.

Phase 4: TypeScript Playground Integration

Installed just-bash and wired up browser-side shell execution in the promptfiddle web worker. Created BamlVfsAdapter to 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 plain uint8array fields. This eliminated the IoClassSysShellOutput trait, all associated impl blocks across 3 crates, the wiring closures, and the Arc<(Vec<u8>, Vec<u8>)> allocation — ~60 lines of boilerplate removed.

Added uint8array.to_string() (lossy UTF-8 decode) and string.to_bytes() (UTF-8 encode) as general-purpose $rust_function builtins, 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 callFunctionResult payload shapes — worker JSON-stringified the result (losing Uint8Array as native bytes and bigint precision), while WebSocket posted decoded objects directly. Unified both to post BamlJsValue<PlainHandleDescriptor> directly via structured clone:

  • baml-lsp-worker.ts — Worker wrapHandle now returns POJOs instead of live BamlHandle objects; removed jsonReplacer + JSON.stringify from result paths
  • ExecutionPanel.tsx — Removed typeof/JSON.parse normalization guard; pending.resolve(data.result) directly
  • worker-protocol.tscallFunctionResult.result typed as BamlJsValue<PlainHandleDescriptor>
  • format-value.ts — Fixed $handle case to read value.handle.handle_key
  • pkg-proto/types.ts — Added PlainHandleDescriptor type
  • New test suites: 5 structuredClone round-trip tests in pkg-proto, 7 format-value tests in pkg-playground

Also fixed LSP runtime panics discovered during testing:

  • wasm_helpers.rs — Replaced run_async_in_background free function with BackgroundSpawner that makes the tokio runtime dependency explicit (native: Handle::spawn, WASM: spawn_local)
  • baml_lsp_server/src/lib.rs — Default tracing filter changed to info,salsa=warn to silence debug noise

Supporting Changes

  • conversion.rs — Made RustData conversion non-fatal so handle classes survive the engine pipeline
  • value_encode.rsRustData over FFI serializes as "<native handle>" instead of erroring
  • vertex.rs — Updated Vertex auth code to use new ShellOutput API (.stdout is now Vec<u8>, decoded via from_utf8_lossy)
  • codegen.rs — Fixed test disambiguation for methods with same name across classes (to_string)
  • codegen_io.rs — Fixed into_owned_expr to handle BamlType::Uint8Array fields
  • next.config.js — Webpack polyfills for just-bash node: imports
  • ShellOutput.tsx — Custom playground result renderer with tabbed stdout/stderr, exit code badge, and copy button
  • decode.ts — Added uint8arrayValue case (was silently null)
  • ValueRenderer.tsx — Fixed renderer fallthrough logic

Deviations from the plan

Implemented as planned

  • Phases 1-2 (ShellOutput class, exec()+ProcessOptions, error contract, native impl, stubs) match plan structure
  • WASM bridge and TypeScript playground integration match plan phases 4-5
  • ok() as pure-BAML method

Deviations/surprises

  • Phase 3 superseded: The plan called for promoting ShellOutput to a handle class with _output $rust_type + stdout_bytes()/stderr_bytes() IO methods (following http.Response pattern). This was implemented, then replaced with a simpler bytes-only design — stdout/stderr are uint8array fields directly, no handle class needed. The http.Response pattern exists because HTTP bodies may be streaming/lazy; shell output is always fully captured, so the laziness is unnecessary overhead.
  • uint8array.to_string() and string.to_bytes() added as general-purpose builtins (not in plan) — prerequisite for the bytes-only approach
  • Timeout implementation: Uses tokio::spawn + join handle instead of direct child.wait_with_output() wrapping — works around ownership issues with killing after move
  • exec() in browser: Routes through just-bash shell interpreter rather than true direct-exec, unavoidable given just-bash's API. Native path correctly uses Command::new(program) directly
  • Unified callFunctionResult serialization: Worker previously JSON-stringified results (losing native Uint8Array and bigint); now both worker and WebSocket paths post decoded BamlJsValue<PlainHandleDescriptor> directly via structured clone

Additions not in plan

  • uint8array.to_string() / string.to_bytes() — General-purpose UTF-8 conversion builtins
  • ShellOutput.tsx custom renderer — Playground result renderer with tabbed stdout/stderr
  • Unified callFunctionResult serialization — Eliminated worker/WebSocket payload divergence; added PlainHandleDescriptor type, structuredClone round-trip tests, and format-value tests
  • BackgroundSpawner — Fixed LSP "no reactor running" panic by making tokio runtime dependency explicit for run_async_in_background
  • conversion.rs fixRustData conversion made non-fatal
  • value_encode.rs fixRustData serializes as "<native handle>" over FFI
  • codegen.rs test fix — Disambiguate to_string method across classes
  • codegen_io.rs fixinto_owned_expr for Uint8Array fields
  • decode.ts / types.tsuint8arrayValue support in pkg-proto
  • vertex.rs call site update — Required but not listed in plan
  • next.config.js webpack polyfills — Required for just-bash node: imports
  • LSP log level — Default tracing filter info,salsa=warn to silence debug noise

Items planned but not implemented

  • stdout_bytes() / stderr_bytes() IO methods — Superseded by making stdout/stderr uint8array fields directly. The handle class pattern was unnecessary for fully-captured output.
  • _output $rust_type field — Eliminated along with the IO methods
  • IoClassSysShellOutput trait + impls — No longer needed

How to verify it

Setup

git fetch
git worktree add ~/wt/baml-3/redesign-stdlib-shell origin/hellovai/redesign-stdlib-shell
cd ~/wt/baml-3/redesign-stdlib-shell

Automated Tests

cd baml_language
cargo test -p baml_tests -- shell
cargo test -p baml_builtins2_codegen
cargo build -p bridge_wasm --target wasm32-unknown-unknown
cd ../typescript2
pnpm -F @b/pkg-proto test
pnpm -F @b/pkg-playground test

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' — returns 1, does NOT throw
  • baml_cli run -e 'baml.sys.shell("echo hi").ok()' — returns true
  • baml_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"
  • Playground: run baml.sys.shell("echo hello") in browser — verify ShellOutput renderer displays decoded bytes

Description for the changelog

Redesign shell stdlib: shell() and new exec() return ShellOutput { stdout: uint8array, stderr: uint8array, exit_code: int } with ok() method. Subprocess output is bytes-first (use .to_string() for text). Add uint8array.to_string() and string.to_bytes() builtins. Add ProcessOptions for cwd/env/timeout/stdin. Non-zero exits no longer throw. WASM/playground support via just-bash.

hellovai and others added 9 commits April 29, 2026 16:07
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
baml-website-redesign Error Error Apr 30, 2026 11:24pm
beps Ready Ready Preview, Comment Apr 30, 2026 11:24pm
promptfiddle Ready Ready Preview, Comment Apr 30, 2026 11:24pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Shell 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

Cohort / File(s) Summary
Builtins API
baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml
Replaced shell(command) -> string with shell(command, options?) -> ShellOutput; added exec(program, args?, options?) -> ShellOutput, ProcessOptions, and ShellOutput.
Native runtime
baml_language/crates/sys_native/src/io_impls.rs
Introduced shared run_process(), added exec(), updated shell() to return ShellOutput with stdout/stderr byte capture, stdin support, env/cwd handling, and timeout -> Timeout error.
WASM bridge
baml_language/crates/bridge_wasm/src/lib.rs, .../wasm_sys.rs
Added exec/shell JS callback types, changed WasmSys to hold callbacks, serialize ProcessOptions to JS, await Promise results and unpack to Rust ShellOutput.
Sys ops & types
baml_language/crates/sys_ops/src/lib.rs, baml_language/crates/sys_types/src/lib.rs
Wired new exec sys-op, changed shell signatures/return type to ShellOutput, and added OpErrorKind::Io (maps to SysOpErrorCategory::Io).
Tests
baml_language/crates/baml_tests/.../shell_ops.baml, baml_language/crates/baml_tests/tests/shell.rs
Updated tests to consume ShellOutput fields (stdout/stderr/exit_code), added tests for .ok(), byte-array accessors, exec behavior, ProcessOptions (cwd, stdin, timeout).
Conversion & encoding
baml_language/crates/bex_engine/src/conversion.rs, baml_language/crates/bridge_ctypes/src/value_encode.rs, .../codegen_io.rs
Made RustData conversion non-fatal (preserve as external/placeholder), changed external-to-baml fallback for unsupported RustData to "", and route Uint8Array through typed owned conversion.
BAML primitives
baml_language/crates/baml_builtins2/baml_std/baml/string.baml, .../uint8array.baml
Added String.to_bytes() -> uint8array and Uint8Array.to_string() -> string (lossy UTF-8 decoding).
VM implementations
baml_language/crates/bex_vm/src/package_baml/string.rs, .../uint8array.rs
Implemented to_bytes and to_string helpers in PackageBamlImpl for runtime conversions.
Playground & VFS
typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts, .../baml-vfs-adapter.ts, .../package.json
Added just-bash-backed execution handlers (executeShell, executeExec) using BamlVfsAdapter, registered WASM callbacks, and added just-bash dependency.
Frontend UI
typescript2/pkg-playground/src/renderers/ShellOutput.tsx, .../registerBuiltins.ts, .../ValueRenderer.tsx, .../ExecutionPanel.tsx
Added ShellOutput renderer, registered it, adjusted result parsing and rendering fallbacks to support Uint8Array and ShellOutput (stdout/stderr tabs, exit_code badge).
Playground tests & stubs
typescript2/.../bridge_wasm.test.ts
Added stubbed exec/shell callbacks returning consistent failure results for browser tests.
Misc / infra
typescript2/app-promptfiddle/next.config.js, baml_language/crates/bex_events/src/serialize.rs
Webpack NormalModuleReplacement for node: imports and zlib fallback; minor whitespace tweak in serializer.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes and exit codes so bright,
I carried stdout, stderr through the night.
With ProcessOptions guiding every race,
From native shells to WASM's tiny place.
A carrot-cheer for runs that end just right! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically summarizes the main changes: introducing a redesigned shell API with structured ShellOutput, new exec() function, and WASM support.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hellovai/redesign-stdlib-shell

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Update the unit test for the new RustData fallback.

external_to_baml_value() now returns StringValue("<native handle>") for unconvertible RustData, so this assertion is stale and cargo test --lib should 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 --lib if 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 | 🟠 Major

Scope the node: rewrite to client bundles only.

This webpack callback runs for both server and client compilations. The NormalModuleReplacementPlugin and resolve.fallback configuration 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 !isServer to 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

📥 Commits

Reviewing files that changed from the base of the PR and between 27387d4 and 6aaec70.

⛔ Files ignored due to path filters (15)
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__01_lexer__shell_ops.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__02_parser__shell_ops.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__10_formatter__shell_ops.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
  • typescript2/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml
  • baml_language/crates/baml_tests/projects/builtin_io/shell_ops.baml
  • baml_language/crates/baml_tests/tests/shell.rs
  • baml_language/crates/bex_engine/src/conversion.rs
  • baml_language/crates/bex_events/src/serialize.rs
  • baml_language/crates/bridge_ctypes/src/value_encode.rs
  • baml_language/crates/bridge_wasm/src/lib.rs
  • baml_language/crates/bridge_wasm/src/wasm_sys.rs
  • baml_language/crates/sys_llm/src/auth_request/vertex.rs
  • baml_language/crates/sys_native/src/io_impls.rs
  • baml_language/crates/sys_ops/src/lib.rs
  • baml_language/crates/sys_types/src/lib.rs
  • typescript2/app-promptfiddle/next.config.js
  • typescript2/app-promptfiddle/package.json
  • typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts
  • typescript2/app-promptfiddle/src/playground/baml-vfs-adapter.ts
  • typescript2/app-vscode-webview/src/bridge_wasm.test.ts
  • typescript2/pkg-playground/src/ExecutionPanel.tsx
  • typescript2/pkg-playground/src/ValueRenderer.tsx
  • typescript2/pkg-playground/src/renderers/ShellOutput.tsx
  • typescript2/pkg-playground/src/renderers/registerBuiltins.ts
💤 Files with no reviewable changes (1)
  • baml_language/crates/bex_events/src/serialize.rs

Comment thread baml_language/crates/bridge_wasm/src/wasm_sys.rs
Comment thread baml_language/crates/sys_llm/src/auth_request/vertex.rs Outdated
Comment thread baml_language/crates/sys_native/src/io_impls.rs
Comment thread baml_language/crates/sys_native/src/io_impls.rs Outdated
Comment thread typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts
Comment thread typescript2/app-promptfiddle/src/playground/baml-vfs-adapter.ts
Comment thread typescript2/pkg-playground/src/ExecutionPanel.tsx
Comment thread typescript2/pkg-playground/src/renderers/ShellOutput.tsx
…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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
baml_language/crates/sys_llm/src/auth_request/vertex.rs (1)

211-217: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Check exit_code before trusting gcloud stdout.

ShellOutput now returns non-zero exits as values, so these fallbacks can still accept stale stdout from failed commands. Require output.exit_code == 0 before selecting the CLI path or extracting token / 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_credentials and project_id_from_credentials gcloud 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 win

Inline mode still drops the actual shell output.

summary is computed and expanded state is tracked, but the inline branch only renders the exit-code badge. In event-log / inline-hint contexts that makes stdout and stderr impossible 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 win

Timeouts 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 unescaped program into a shell command.

Line 223 builds a command string and passes it to bash.exec(), but only args are quoted. If program contains shell metacharacters, the browser exec() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6aaec70 and 471a207.

⛔ Files ignored due to path filters (8)
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____03_hir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/__baml_std__/baml_tests____baml_std____06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_5_mir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__04_tir.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/builtin_io/baml_tests__builtin_io__05_diagnostics.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snap is excluded by !**/*.snap
📒 Files selected for processing (15)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_sys/sys.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/string.baml
  • baml_language/crates/baml_builtins2/baml_std/baml/uint8array.baml
  • baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs
  • baml_language/crates/baml_tests/tests/shell.rs
  • baml_language/crates/bex_vm/src/package_baml/string.rs
  • baml_language/crates/bex_vm/src/package_baml/uint8array.rs
  • baml_language/crates/bridge_wasm/src/wasm_sys.rs
  • baml_language/crates/sys_llm/src/auth_request/vertex.rs
  • baml_language/crates/sys_native/src/io_impls.rs
  • baml_language/crates/sys_ops/src/lib.rs
  • typescript2/app-promptfiddle/src/playground/baml-lsp-worker.ts
  • typescript2/pkg-playground/src/renderers/ShellOutput.tsx
  • typescript2/pkg-proto/src/decode.ts
  • typescript2/pkg-proto/src/types.ts
✅ Files skipped from review due to trivial changes (1)
  • typescript2/pkg-proto/src/types.ts

Comment thread baml_language/crates/sys_native/src/io_impls.rs
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>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
baml_language/crates/sys_native/src/io_impls.rs (1)

751-768: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Timeout still leaves the child running in the background.

Dropping the JoinHandle created on Line 753 only detaches the task; it does not cancel it. That means task_child.wait_with_output() keeps owning the Child, so kill_on_drop(true) never fires in the timeout branch and the process can continue running after this function has already returned OpErrorKind::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

📥 Commits

Reviewing files that changed from the base of the PR and between e5284c8 and 501af42.

📒 Files selected for processing (2)
  • baml_language/crates/sys_native/src/io_impls.rs
  • typescript2/pkg-playground/src/ExecutionPanel.tsx

Comment thread baml_language/crates/sys_native/src/io_impls.rs
Comment thread typescript2/pkg-playground/src/ExecutionPanel.tsx Outdated
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
@hellovai hellovai added this pull request to the merge queue Apr 30, 2026
Merged via the queue into canary with commit b7294ff Apr 30, 2026
43 of 44 checks passed
@hellovai hellovai deleted the hellovai/redesign-stdlib-shell branch April 30, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant