Skip to content

Compile the browser-automation core for wasm32: lib/bin split, rt abstraction, injected CDP transport, embedding seams - #1

Open
Glavin001 wants to merge 11 commits into
mainfrom
devin/1786050474-wasm-core
Open

Compile the browser-automation core for wasm32: lib/bin split, rt abstraction, injected CDP transport, embedding seams#1
Glavin001 wants to merge 11 commits into
mainfrom
devin/1786050474-wasm-core

Conversation

@Glavin001

@Glavin001 Glavin001 commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Makes the browser-automation core of agent-browser compile and run as a library on wasm32-unknown-unknown so non-native hosts (e.g. Cloudflare Workers) can embed the command core (dispatch, snapshot/refs, interaction, network, cookies, storage) and drive a remote browser over an injected CDP WebSocket, without forking the automation semantics. Native CLI/daemon behavior is unchanged, and no host-specific code is added.

Building blocks:

  1. lib/bin split — adds src/lib.rs so the crate exposes a library (agent_browser) alongside the existing binary; main.rs imports from the lib. Native-only top-level modules (chat, doctor, mcp, skills, upgrade) are gated off the wasm build.

  2. rt module (src/rt.rs) — a small runtime abstraction over task spawning and timers:

    crate::rt::{spawn, spawn_blocking, sleep, sleep_until, timeout, timeout_at, interval, Instant, JoinHandle}

    Native delegates to tokio (rt::Instant is std::time::Instant). The wasm implementation drives the same API from the JS event loop (setTimeout, wasm_bindgen_futures::spawn_local, an abortable JoinHandle that tracks completion). The gating rule is deliberate and narrow: only code that is compiled on wasm switches to crate::rt; test modules and cfg-gated native-only functions keep calling tokio directly, exactly as on main.

  3. Injected CDP transport (cdp/client.rs) — the WebSocket is abstracted behind:

    pub trait CdpTransportSink { async fn send_text(&self, text: String) -> Result<(), String>; async fn send_ping(&self) -> Result<(), String>; }
    pub enum TransportEvent { Text(String), Close(Option<String>), Error(String) }
    pub fn CdpClient::from_transport(sink: Arc<dyn CdpTransportSink>, events: TransportEventStream) -> Self

    connect/connect_with_headers become thin native wrappers that build the tungstenite transport and call from_transport; command multiplexing, the pending-response map, and event broadcast are unchanged and now portable. BrowserManager::from_client(client, ws_url, direct_page) builds a manager around such a client (native connect_cdp now goes through it too).

  4. Library-host seamsoutput.rs gains begin_capture()/end_capture() -> CapturedOutput: internal outln!/out!/errln! macros buffer the exact CLI output text when a capture is active and print normally otherwise. src/artifacts.rs routes file-producing commands (screenshot, PDF, HAR, diff image) through artifacts::write, which falls back to std::fs::write unless a host installs a writer via set_artifact_writer. Native behavior is identical when no capture/writer is active.

  5. Target gating — native-only subsystems (daemon socket server, local Chrome launch/discovery, inspect server, stream/dashboard, WebDriver/Appium transports, ffmpeg recording, plugin subprocesses, install, the read command) are #[cfg(not(target_arch = "wasm32"))]-gated, with explicit "not supported on this platform" errors where a runtime path could be reached. read stays native-only because its client-level timeout and per-hop redirect allowlist cannot be enforced by reqwest's wasm backend. Cargo.toml splits target-specific dependencies: native keeps full tokio, tokio-tungstenite, reqwest-rustls, socket2; wasm adds wasm-bindgen, js-sys, wasm-bindgen-futures with tokio limited to macros+sync. (The wasm reqwest block intentionally omits rustls-tls-webpki-roots: on wasm32 reqwest delegates TLS to the host fetch implementation and the rustls stack does not build there.)

  6. wasm-safe gen_idcommands::gen_id derives its microsecond timestamp from js_sys::Date::now() on wasm32; std::time::SystemTime::now() aborts on wasm32-unknown-unknown (found by running the core in workerd, where parse_command trapped).

The Default impls (DaemonState, RefMap, EventTracker, RecordingState, TracingState) and the #[allow(clippy::should_implement_trait)] on WaitUntil::from_str are needed because clippy lints these pub items now that they are on a lib target (CI runs clippy -- -D warnings).

Verification

All from cli/:

  • cargo check --all-targets: clean
  • cargo check --lib --target wasm32-unknown-unknown: clean (0 errors, 0 warnings)
  • cargo clippy --all-targets -- -D warnings: clean
  • cargo fmt -- --check: clean
  • cargo test: 1065 passed, 0 failed
  • Downstream validation: the wasm library was exercised end-to-end in a Cloudflare Worker (workerd) via the injected transport — open/snapshot/screenshot against a real Chrome over CDP, with byte-identical CLI stdout via the output-capture seam.
  • Native e2e (cargo test e2e -- --ignored --test-threads=1) was verified earlier to behave identically to main in the same environment; native code paths keep using tokio directly wherever they are not compiled for wasm, so native behavior is unchanged by construction (on native targets crate::rt re-exports the tokio primitives).

Link to Devin session: https://app.devin.ai/sessions/5c759851c2274cd7a02ace63935862ef
Requested by: @Glavin001


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

devin-ai-integration Bot and others added 5 commits August 6, 2026 21:11
Expose the existing modules through src/lib.rs so the crate can be
consumed as a library; src/main.rs keeps the CLI entrypoint and now
imports those modules from the lib.

Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
…s can supply their own WebSocket

Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
@Glavin001 Glavin001 self-assigned this Aug 6, 2026
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

The wasm read path silently dropped the timeout and redirect-allowlist
enforcement, so gate run_read to native targets and return an
unsupported-platform error on wasm32. Track task completion with an
AtomicBool so JoinHandle::is_finished reports finished tasks on wasm.

Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown

Tested native parity of this branch end-to-end against real headless Chrome.

Result: no behavioral difference from upstream main observed.

  • Full CLI flow on the branch (opensnapshotclick @refsnapshotscreenshotclose) works; AX ref tree renders as expected (button "Click me" [ref=e2]), and clicking via ref actually mutated the page (heading "Clicked!" in the next snapshot).
  • Ran the identical sequence with a main build in a separate worktree: transcripts identical, and the two screenshots are byte-identical PNGs (cmp clean).
  • cargo check --lib --target wasm32-unknown-unknown: clean, exit 0.
  • cd cli && cargo test: 1065 passed, 0 failed (97 ignored e2e).

Branch CLI screenshot of test page

Branch CLI transcript (all exit codes 0)
✓ AB Test Page
  http://localhost:8000/test.html
- heading "Hello" [level=1, ref=e1]
- button "Click me" [ref=e2]
✓ Done                      (click @e2)
- heading "Clicked!" [level=1, ref=e1]
- button "Click me" [ref=e2]
✓ Screenshot saved to /tmp/abtest/branch.png
✓ Browser closed
Environmental note on the earlier DevToolsActivePort failure

The "Chrome exited early without writing DevToolsActivePort" failure in this environment is caused by google-chrome being a browser shim script, not a real Chrome. Pointing AGENT_BROWSER_EXECUTABLE_PATH at a real Chrome binary with --args "--no-sandbox --disable-dev-shm-usage" resolves it on both this branch and main.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread cli/Cargo.toml
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["v4", "js"] }
dirs = "5.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream"] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 wasm reqwest client drops rustls-webpki-roots feature used on native

The new wasm dependency block configures reqwest without the rustls-tls-webpki-roots feature that the native block keeps (cli/Cargo.toml:38 vs cli/Cargo.toml:46). On wasm32 reqwest delegates TLS to the host fetch implementation, so this is not directly exploitable, but the asymmetry means any future non-browser wasm host would get a reqwest build with no configured trust anchors rather than the pinned webpki root set the native build relies on.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Intentional: on wasm32 reqwest uses the browser/host fetch backend and delegates TLS entirely to the host, so rustls-tls-webpki-roots has no effect there — and the rustls/ring stack it pulls in does not build for wasm32-unknown-unknown. Enabling it in the wasm block would break the wasm build without adding any trust-anchor behavior. Noted the rationale in the PR description.

devin-ai-integration Bot and others added 4 commits August 6, 2026 22:25
…y where wasm compiles it

Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
@devin-ai-integration devin-ai-integration Bot changed the title Compile the browser-automation core for wasm32: lib/bin split, rt abstraction, injected CDP transport Compile the browser-automation core for wasm32: lib/bin split, rt abstraction, injected CDP transport, embedding seams Aug 6, 2026
Co-Authored-By: glavin@coframe.com <glavin.wiechert@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown

End-to-end test results (Devin)

Verified native CLI behavior is unchanged on this branch by building the release binary (Rust 1.97.1) and exercising it against real Chrome 143 (--no-sandbox --disable-dev-shm-usage).

Golden path (all passed): opensnapshot (refs) → typeclick (form submitted) → screenshotpdfnetwork har stop <path>close. Screenshot/PDF/HAR files were actually written through the new artifacts::write route (valid PNG 1280x581, %PDF-1.4 2 pages, HAR JSON with 10 entries).

Screenshot artifact written via artifacts::write

Output seam: --help output is byte-identical to a freshly built main binary (diff clean), confirming the outln!/out! refactor didn't change stdout formatting.

Full e2e suite against real Chrome
cargo test --release e2e -- --ignored --test-threads=1
test result: ok. 96 passed; 0 failed; 0 ignored; 1044 filtered out; finished in 70.96s

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