Skip to content

Commit 6af414a

Browse files
committed
feat: add FX WebAssembly ACP agent
1 parent 44a6c80 commit 6af414a

27 files changed

Lines changed: 1451 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
node-version: 24
2929
cache: pnpm
3030
cache-dependency-path: pnpm-lock.yaml
31+
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
32+
with:
33+
version: 0.16.0
3134
- uses: dtolnay/rust-toolchain@stable
3235
with:
3336
components: rustfmt

.github/workflows/publish.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ jobs:
257257
cache: pnpm
258258
cache-dependency-path: pnpm-lock.yaml
259259
registry-url: https://registry.npmjs.org
260+
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
261+
with:
262+
version: 0.16.0
260263
- run: pnpm install --frozen-lockfile --filter='!@rivet-dev/agentos-website'
261264
- uses: actions/download-artifact@v4
262265
with:

crates/execution/assets/runners/wasm-runner.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6954,6 +6954,7 @@ const hostProcessImport = {
69546954
argv0,
69556955
cwd,
69566956
env,
6957+
envProvided: true,
69576958
internalBootstrapEnv: {
69586959
...(activeSpawnCallContext?.internalBootstrapEnv ?? {}),
69596960
...inheritedNofileBootstrapEnv(),

crates/native-sidecar/src/execution/child_process.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3252,8 +3252,11 @@ where
32523252
vm.host_cwd.clone()
32533253
}
32543254
});
3255-
let mut env = parent_env.clone();
3256-
env.extend(request.options.env.clone());
3255+
let mut env = if request.options.env_provided {
3256+
request.options.env.clone()
3257+
} else {
3258+
parent_env.clone()
3259+
};
32573260
// Child JavaScript executions must resolve their own entrypoint/eval state.
32583261
// Reusing the parent's values makes the sidecar load the wrong source file.
32593262
env.remove("AGENTOS_GUEST_ENTRYPOINT");

crates/native-sidecar/src/service.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4095,6 +4095,7 @@ mod legacy_child_spawn_options_tests {
40954095
"executableFd":11,
40964096
"cwd":"/work",
40974097
"env":{"VISIBLE":"yes"},
4098+
"envProvided":true,
40984099
"internalBootstrapEnv":{
40994100
"AGENTOS_WASM_INITIAL_SIGNAL_MASK":"[10]",
41004101
"AGENTOS_NOT_ALLOWED":"drop-me-too"
@@ -4137,6 +4138,7 @@ mod legacy_child_spawn_options_tests {
41374138
assert_eq!(options.executable_fd, Some(11));
41384139
assert_eq!(options.cwd.as_deref(), Some("/work"));
41394140
assert_eq!(options.env.get("VISIBLE").map(String::as_str), Some("yes"));
4141+
assert!(options.env_provided);
41404142
assert_eq!(
41414143
options
41424144
.internal_bootstrap_env

crates/native-sidecar/tests/service.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11964,6 +11964,62 @@ console.log(JSON.stringify({ status: "ok", summary }));
1196411964
"missing command error should mention the command: {error}"
1196511965
);
1196611966

11967+
let mut parent_env = vm.guest_env.clone();
11968+
parent_env.insert(
11969+
String::from("AI_GATEWAY_API_KEY"),
11970+
String::from("must-not-leak"),
11971+
);
11972+
let replacement_env = BTreeMap::from([
11973+
(
11974+
String::from("PATH"),
11975+
vm.guest_env
11976+
.get("PATH")
11977+
.expect("configured PATH should exist")
11978+
.clone(),
11979+
),
11980+
(String::from("WORKSPACE_ONLY"), String::from("yes")),
11981+
]);
11982+
let replaced = sidecar
11983+
.resolve_javascript_child_process_execution(
11984+
vm,
11985+
&parent_env,
11986+
&vm.guest_cwd,
11987+
&vm.host_cwd,
11988+
&crate::protocol::JavascriptChildProcessSpawnRequest {
11989+
command: String::from("echo"),
11990+
args: vec![String::from("hello")],
11991+
options: crate::protocol::JavascriptChildProcessSpawnOptions {
11992+
env: replacement_env,
11993+
env_provided: true,
11994+
..Default::default()
11995+
},
11996+
},
11997+
)
11998+
.expect("resolve child with explicit replacement environment");
11999+
assert_eq!(
12000+
replaced.env.get("WORKSPACE_ONLY").map(String::as_str),
12001+
Some("yes")
12002+
);
12003+
assert!(!replaced.env.contains_key("AI_GATEWAY_API_KEY"));
12004+
12005+
let inherited = sidecar
12006+
.resolve_javascript_child_process_execution(
12007+
vm,
12008+
&parent_env,
12009+
&vm.guest_cwd,
12010+
&vm.host_cwd,
12011+
&crate::protocol::JavascriptChildProcessSpawnRequest {
12012+
command: String::from("echo"),
12013+
args: vec![String::from("hello")],
12014+
options: crate::protocol::JavascriptChildProcessSpawnOptions::default(),
12015+
},
12016+
)
12017+
.expect("resolve child with inherited environment");
12018+
assert_eq!(
12019+
inherited.env.get("AI_GATEWAY_API_KEY").map(String::as_str),
12020+
Some("must-not-leak")
12021+
);
12022+
1196712023
// execve resolves a literal relative/absolute pathname and must
1196812024
// not reuse spawnp's basename fallback. `/workspace/echo` does not
1196912025
// exist even though an `echo` command is installed on PATH.
@@ -25625,6 +25681,11 @@ try {
2562525681
run_isolated_service_test("javascript-fs-promises-hot-metadata");
2562625682
}
2562725683

25684+
#[test]
25685+
fn javascript_child_process_explicit_env_replaces_parent_regression() {
25686+
javascript_child_process_searches_path_for_mounted_wasm_commands();
25687+
}
25688+
2562825689
#[test]
2562925690
fn wasm_shell_external_stdout_redirect_writes_file_regression() {
2563025691
run_isolated_service_test("wasm-shell-external-stdout-redirect");

crates/sidecar-protocol/src/protocol.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3275,6 +3275,11 @@ pub struct JavascriptChildProcessSpawnOptions {
32753275
pub cwd: Option<String>,
32763276
#[serde(default)]
32773277
pub env: BTreeMap<String, String>,
3278+
/// Whether the caller supplied `options.env`. Node inherits the parent
3279+
/// environment only when this option is omitted; an explicitly supplied
3280+
/// map, including an empty one, replaces it.
3281+
#[serde(rename = "envProvided", default)]
3282+
pub env_provided: bool,
32783283
#[serde(rename = "internalBootstrapEnv", default)]
32793284
pub internal_bootstrap_env: BTreeMap<String, String>,
32803285
/// POSIX spawn attributes already validated by the WASM host-import

crates/v8-runtime/src/isolate.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ pub fn init_v8_platform() {
133133
.spawn(move || {
134134
v8::icu::set_common_data_74(&ICU_COMMON_DATA.0)
135135
.expect("failed to initialize V8 ICU common data");
136+
// FX's browser/WebAssembly SDK suspends guest WASM across
137+
// asynchronous host fetch and workspace calls through JSPI.
138+
// Flags are process-global and must be set before V8 starts.
139+
v8::V8::set_flags_from_string("--experimental-wasm-jspi");
136140
let platform =
137141
v8::new_default_platform(V8_PLATFORM_WORKER_THREADS, false).make_shared();
138142
v8::V8::initialize_platform(platform);

crates/v8-runtime/tests/event_loop.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::thread::JoinHandle;
99
use std::time::{Duration, Instant};
1010

1111
const WASM_FORTY_TWO_BYTES: &str = "0,97,115,109,1,0,0,0,1,5,1,96,0,1,127,3,2,1,0,7,12,1,8,102,111,114,116,121,84,119,111,0,0,10,6,1,4,0,65,42,11";
12+
const WASM_JSPI_IMPORT_BYTES: &str = "0,97,115,109,1,0,0,0,1,5,1,96,0,1,127,2,19,1,3,101,110,118,11,97,115,121,110,99,95,118,97,108,117,101,0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,6,1,4,0,16,0,11";
1213
const EVENT_LOOP_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(6);
1314

1415
// Timing-sensitive assertions flake under the CPU contention of a parallel test
@@ -250,6 +251,75 @@ fn event_loop_completes_native_async_wasm_instantiate_promises() {
250251
);
251252
}
252253

254+
fn event_loop_resumes_jspi_guest_wasm_imports() {
255+
isolate::init_v8_platform();
256+
257+
let mut isolate = isolate::create_isolate(None);
258+
let context = isolate::create_context(&mut isolate);
259+
let pending = PendingPromises::new();
260+
let (_tx, rx) = crossbeam_channel::unbounded::<SessionCommand>();
261+
let mut bridge_cache = None;
262+
263+
let scope = &mut v8::HandleScope::new(&mut isolate);
264+
let ctx = v8::Local::new(scope, &context);
265+
let scope = &mut v8::ContextScope::new(scope, ctx);
266+
267+
let source = format!(
268+
"globalThis.__jspiResult = null; \
269+
(async () => {{ \
270+
const module = new WebAssembly.Module(new Uint8Array([{bytes}])); \
271+
const asyncValue = new WebAssembly.Suspending(async () => {{ \
272+
await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])); \
273+
return 42; \
274+
}}); \
275+
const instance = new WebAssembly.Instance(module, {{ env: {{ async_value: asyncValue }} }}); \
276+
globalThis.__jspiResult = await WebAssembly.promising(instance.exports.run)(); \
277+
}})();",
278+
bytes = WASM_JSPI_IMPORT_BYTES
279+
);
280+
281+
let (code, error) = execution::execute_script(scope, "", &source, &mut bridge_cache);
282+
assert_eq!(code, 0, "unexpected execute_script exit code");
283+
assert!(
284+
error.is_none(),
285+
"unexpected execute_script error: {error:?}"
286+
);
287+
assert!(
288+
execution::has_pending_script_evaluation(),
289+
"expected pending script evaluation for a JSPI-suspended WASM import"
290+
);
291+
292+
let status = run_event_loop_with_watchdog(scope, &rx, &pending);
293+
assert_event_loop_watchdog_did_not_fire(&status);
294+
assert!(
295+
matches!(status, EventLoopStatus::Completed),
296+
"unexpected event loop status: {:?}",
297+
status
298+
);
299+
300+
if let Some((next_code, next_error)) = execution::finalize_pending_script_evaluation(scope) {
301+
assert_eq!(next_code, 0, "unexpected finalize exit code");
302+
assert!(
303+
next_error.is_none(),
304+
"unexpected finalize error: {next_error:?}"
305+
);
306+
}
307+
308+
let source = v8::String::new(
309+
scope,
310+
"typeof WebAssembly.Suspending === 'function' && \
311+
typeof WebAssembly.promising === 'function' && \
312+
globalThis.__jspiResult === 42",
313+
)
314+
.unwrap();
315+
let script = v8::Script::compile(scope, source, None).unwrap();
316+
let result = script.run(scope).unwrap();
317+
assert!(
318+
result.boolean_value(scope),
319+
"expected JSPI to resume WASM after the asynchronous host import"
320+
);
321+
}
322+
253323
fn event_loop_surfaces_native_async_wasm_compile_errors_without_hanging() {
254324
isolate::init_v8_platform();
255325

@@ -406,6 +476,7 @@ fn event_loop_handles_native_async_wasm_paths_without_hanging() {
406476
fresh_isolate_supports_rgi_emoji_unicode_sets();
407477
event_loop_pumps_v8_platform_tasks_for_native_wasm_promises();
408478
event_loop_completes_native_async_wasm_instantiate_promises();
479+
event_loop_resumes_jspi_guest_wasm_imports();
409480
event_loop_surfaces_native_async_wasm_compile_errors_without_hanging();
410481
event_loop_waits_for_refed_guest_timers_between_interval_ticks();
411482
}

docs/content/docs/architecture.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ The executor is the untrusted half of the VM. It runs the guest code and reaches
176176

177177
- **JavaScript Acceleration.** Guest JavaScript runs on a native V8 runtime (the same engine in Chrome and Node.js, with the full JIT compiler) inside an isolate. This is what we call **JavaScript Acceleration**: the guest's JavaScript executes at native speed, not through an interpreter or a translation shim. It is genuinely fast, and it presents normal Node.js semantics. See [JavaScript](/agentos/docs/javascript).
178178
- **WASM alongside it.** The shell (`sh`) and the coreutils behind process execution ship as WebAssembly modules, and you can run your own WASM too. See [POSIX Syscalls](/agentos/docs/architecture/posix-syscalls) and the [Compiler Toolchain](/agentos/docs/architecture/compiler-toolchain).
179+
- **Web WASM inside V8.** Browser-targeted modules can run inside a JavaScript process and await JavaScript host functions through JSPI. See [Async WebAssembly & JSPI](/agentos/docs/architecture/async-webassembly).
179180
- **Native binaries.** Tools mounted into the VM run inside the same boundary as everything else.
180181
- **No host fallthrough.** The executor holds no capability of its own. For every file read, process spawn, or socket open, it issues a syscall and blocks for the kernel's reply.
181182

@@ -359,6 +360,7 @@ This page is the map. Each subsystem has its own detailed page in the Advanced a
359360
- **[Filesystem](/agentos/docs/architecture/filesystem)**: the per-VM virtual filesystem, overlays, and host-backed mounts.
360361
- **[Networking](/agentos/docs/architecture/networking)**: the virtual socket table, DNS, the allowlist, and guest `fetch()`.
361362
- **[JavaScript Executor & Socket Reactor](/agentos/docs/architecture/javascript-executor)**: how the shared Tokio runtime, V8 executor threads, coalesced readiness, bounded channels, and Node stream backpressure fit together.
363+
- **[Async WebAssembly & JSPI](/agentos/docs/architecture/async-webassembly)**: how web-targeted WASM suspends across JavaScript promises without requiring the POSIX sysroot.
362364
- **[POSIX Syscalls](/agentos/docs/architecture/posix-syscalls)**: how WebAssembly guests behave like normal POSIX programs on top of the kernel.
363365
- **[Compiler Toolchain](/agentos/docs/architecture/compiler-toolchain)**: how the shell and coreutils are compiled to WebAssembly and mounted into the VM.
364366
- **[System Prompt](/agentos/docs/system-prompt)**: the context agentOS injects into every agent session.

0 commit comments

Comments
 (0)