Skip to content

Commit 16a2751

Browse files
NathanFlurryclaude
andcommitted
feat(wasm): synthesize standard /dev character devices in the host-backed guest fs
Threaded wasm guests use the host-backed fs (wasm.rs open_wasm_guest_file), which bypasses the kernel device_layer, so opening /dev/null, /dev/urandom, etc. failed with ENOENT. The GTK/X clients never noticed (they use getrandom()), but dbus-daemon opens these device FILES at startup ("setting up standard fds" + UUID generation). Fix (constraint #5, runtime layer): - wasm.rs: a WasmCharDevice table (Null/Zero/Full/Random) parallel to open_files; open synthesizes a device fd, read produces EOF (null) / zeros (zero,full) / getrandom bytes (random,urandom), write is discarded (ENOSPC for full). - node_import_cache.rs: the path_open override now routes the /dev device paths through openGuestFileForPathOpen -> fsModule.openSync (the device handler) regardless of O_CREAT; the default WASI path_open over the sandbox preopens would return ENOENT. Asset version bumped. dbus-daemon now starts past /dev/null + /dev/urandom. No regression: the LXDE openbox client still renders. General fix: any Linux guest needing /dev devices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d950db4 commit 16a2751

3 files changed

Lines changed: 99 additions & 7 deletions

File tree

crates/execution/src/node_import_cache.rs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS_ENV: &str =
1717
"AGENT_OS_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS";
1818
const NODE_IMPORT_CACHE_SCHEMA_VERSION: &str = "1";
1919
const NODE_IMPORT_CACHE_LOADER_VERSION: &str = "8";
20-
const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "99";
20+
const NODE_IMPORT_CACHE_ASSET_VERSION: &str = "100";
2121
const NODE_IMPORT_CACHE_DIR_PREFIX: &str = "agent-os-node-import-cache";
2222
const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT: Duration = Duration::from_secs(30);
2323
const PYODIDE_DIST_DIR: &str = "pyodide-dist";
@@ -9645,22 +9645,36 @@ function allocateSyntheticFd() {
96459645
return fd;
96469646
}
96479647

9648+
// Standard /dev character devices. These are synthesized by the host fs (fs.openSync), not real files,
9649+
// so the host-backed path_open must route them through fsModule.openSync regardless of O_CREAT (the
9650+
// default WASI path_open over the sandbox preopens would return ENOENT). dbus-daemon and many Linux
9651+
// programs open /dev/null + /dev/urandom at startup.
9652+
function isDeviceGuestPath(p) {
9653+
return (
9654+
p === '/dev/null' ||
9655+
p === '/dev/zero' ||
9656+
p === '/dev/full' ||
9657+
p === '/dev/random' ||
9658+
p === '/dev/urandom'
9659+
);
9660+
}
9661+
96489662
function openGuestFileForPathOpen(fd, pathPtr, pathLen, oflags, rightsBase, fdflags, openedFdPtr) {
96499663
const normalizedOflags = Number(oflags) >>> 0;
96509664
const normalizedFdflags = Number(fdflags) >>> 0;
9651-
if ((normalizedOflags & WASI_OFLAGS_CREAT) === 0) {
9652-
return null;
9653-
}
9654-
96559665
const guestPath = resolvePathOpenGuestPath(fd, pathPtr, pathLen);
96569666
if (typeof guestPath !== 'string') {
96579667
return null;
96589668
}
9669+
const device = isDeviceGuestPath(guestPath);
9670+
if ((normalizedOflags & WASI_OFLAGS_CREAT) === 0 && !device) {
9671+
return null;
9672+
}
96599673

96609674
const append = (normalizedFdflags & WASI_FDFLAGS_APPEND) !== 0;
96619675
const exclusive = (normalizedOflags & WASI_OFLAGS_EXCL) !== 0;
96629676
const truncate = (normalizedOflags & WASI_OFLAGS_TRUNC) !== 0;
9663-
if (!append && !exclusive && !truncate && !fsModule.existsSync(guestPath)) {
9677+
if (!device && !append && !exclusive && !truncate && !fsModule.existsSync(guestPath)) {
96649678
fsModule.writeFileSync(guestPath, Buffer.alloc(0));
96659679
}
96669680
const targetFd = fsModule.openSync(
@@ -12953,7 +12967,7 @@ if (delegatePathOpen) {
1295312967
) {
1295412968
return denyReadOnlyMutation();
1295512969
}
12956-
if ((Number(oflags) & WASI_OFLAGS_CREAT) !== 0) {
12970+
if ((Number(oflags) & WASI_OFLAGS_CREAT) !== 0 || isDeviceGuestPath(guestPath)) {
1295712971
try {
1295812972
const syntheticResult = openGuestFileForPathOpen(
1295912973
fd,

crates/execution/src/wasm.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,26 @@ pub struct WasmExecution {
350350
stderr_stream_buffer: Vec<u8>,
351351
}
352352

353+
/// Standard character devices the host-backed wasm fs synthesizes (the kernel device_layer is not on
354+
/// this guest's open() path). dbus-daemon and other Linux programs open these device FILES at startup.
355+
#[derive(Clone, Copy, Debug)]
356+
enum WasmCharDevice {
357+
Null, // read -> EOF, write -> discard
358+
Zero, // read -> zeros, write -> discard
359+
Full, // read -> zeros, write -> ENOSPC (rarely used; included for completeness)
360+
Random, // read -> random bytes (also /dev/urandom)
361+
}
362+
363+
fn wasm_char_device_for_path(path: &str) -> Option<WasmCharDevice> {
364+
match path {
365+
"/dev/null" => Some(WasmCharDevice::Null),
366+
"/dev/zero" => Some(WasmCharDevice::Zero),
367+
"/dev/full" => Some(WasmCharDevice::Full),
368+
"/dev/random" | "/dev/urandom" => Some(WasmCharDevice::Random),
369+
_ => None,
370+
}
371+
}
372+
353373
#[derive(Debug)]
354374
struct WasmInternalSyncRpc {
355375
module_guest_paths: Vec<String>,
@@ -360,6 +380,7 @@ struct WasmInternalSyncRpc {
360380
guest_path_mappings: Vec<WasmGuestPathMapping>,
361381
next_fd: u32,
362382
open_files: BTreeMap<u32, fs::File>,
383+
open_devices: BTreeMap<u32, WasmCharDevice>,
363384
pending_events: VecDeque<WasmExecutionEvent>,
364385
}
365386

@@ -900,6 +921,7 @@ impl WasmExecutionEngine {
900921
guest_path_mappings,
901922
next_fd: 64,
902923
open_files: BTreeMap::new(),
924+
open_devices: BTreeMap::new(),
903925
pending_events: VecDeque::new(),
904926
},
905927
})
@@ -993,6 +1015,17 @@ fn handle_internal_wasm_sync_rpc_request(
9931015
"missing fs.openSync path",
9941016
)));
9951017
};
1018+
// Synthesize the standard /dev character devices (the kernel device_layer is not on this
1019+
// host-backed guest fs path). Programs like dbus-daemon open /dev/null + /dev/urandom at start.
1020+
if let Some(device) = wasm_char_device_for_path(path) {
1021+
let fd = internal_sync_rpc.next_fd;
1022+
internal_sync_rpc.next_fd += 1;
1023+
internal_sync_rpc.open_devices.insert(fd, device);
1024+
execution
1025+
.respond_sync_rpc_success(request.id, json!(fd))
1026+
.map_err(map_javascript_error)?;
1027+
return Ok(true);
1028+
}
9961029
let Some(host_path) = translate_wasm_guest_path(path, internal_sync_rpc) else {
9971030
return Ok(false);
9981031
};
@@ -1081,6 +1114,12 @@ fn handle_internal_wasm_sync_rpc_request(
10811114
"missing fs.closeSync fd",
10821115
)));
10831116
};
1117+
if internal_sync_rpc.open_devices.remove(&(fd as u32)).is_some() {
1118+
execution
1119+
.respond_sync_rpc_success(request.id, Value::Null)
1120+
.map_err(map_javascript_error)?;
1121+
return Ok(true);
1122+
}
10841123
if internal_sync_rpc.open_files.remove(&(fd as u32)).is_none() {
10851124
return Ok(false);
10861125
}
@@ -1379,6 +1418,18 @@ fn handle_internal_wasm_sync_rpc_request(
13791418
.map_err(map_javascript_error)?;
13801419
return Ok(true);
13811420
}
1421+
if let Some(device) = internal_sync_rpc.open_devices.get(&(fd as u32)).copied() {
1422+
// /dev/null + /dev/zero + /dev/random discard writes (report all bytes accepted);
1423+
// /dev/full reports ENOSPC.
1424+
let result = match device {
1425+
WasmCharDevice::Full => json!(-1),
1426+
_ => json!(bytes.len()),
1427+
};
1428+
execution
1429+
.respond_sync_rpc_success(request.id, result)
1430+
.map_err(map_javascript_error)?;
1431+
return Ok(true);
1432+
}
13821433
let position = request.args.get(2).and_then(Value::as_u64);
13831434
let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
13841435
return Ok(false);
@@ -1403,6 +1454,31 @@ fn handle_internal_wasm_sync_rpc_request(
14031454
};
14041455
let length = wasm_sync_read_length(request.args.get(1).and_then(Value::as_u64))?;
14051456
let position = request.args.get(2).and_then(Value::as_u64);
1457+
if let Some(device) = internal_sync_rpc.open_devices.get(&(fd as u32)).copied() {
1458+
let buffer: Vec<u8> = match device {
1459+
WasmCharDevice::Null => Vec::new(), // EOF
1460+
WasmCharDevice::Zero | WasmCharDevice::Full => vec![0u8; length],
1461+
WasmCharDevice::Random => {
1462+
let mut b = vec![0u8; length];
1463+
getrandom::getrandom(&mut b).map_err(|e| {
1464+
WasmExecutionError::Spawn(std::io::Error::other(format!(
1465+
"/dev/random getrandom failed: {e}"
1466+
)))
1467+
})?;
1468+
b
1469+
}
1470+
};
1471+
execution
1472+
.respond_sync_rpc_success(
1473+
request.id,
1474+
json!({
1475+
"__agentOsType": "bytes",
1476+
"base64": v8_runtime::base64_encode_pub(&buffer),
1477+
}),
1478+
)
1479+
.map_err(map_javascript_error)?;
1480+
return Ok(true);
1481+
}
14061482
let Some(file) = internal_sync_rpc.open_files.get_mut(&(fd as u32)) else {
14071483
return Ok(false);
14081484
};
@@ -4590,6 +4666,7 @@ fn prewarm_wasm_path(
45904666
guest_path_mappings: wasm_guest_path_mappings(request),
45914667
next_fd: 64,
45924668
open_files: BTreeMap::new(),
4669+
open_devices: BTreeMap::new(),
45934670
pending_events: VecDeque::new(),
45944671
};
45954672
let mut stdout = Vec::new();

experiments/wasm-gui/M8-STATUS-LOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@
2828
2026-06-24T18 (loop iter26 follow-up): CORRECTED iter25's libxcb fcntl repayment — it was applied to the WRONG tree. The clients link the `-threads` variants (libxcb-threads, libX11-threads, libXau-threads, libxdmcp-threads, libxtrans-threads), NOT the bare trees. iter25 reverted third_party/libxcb/src/xcb_conn.c (non-threads, unused by clients) so the threads prefix libxcb.a kept the patch; it rendered green only because the old patch + my fcntl override coexist. Now reverted libxcb-THREADS/src/xcb_conn.c to stock + rebuilt libxcb-threads: threads libxcb.a has 0 secure_exec_net_set_nonblock imports, openbox.wasm links genuinely-stock libxcb. Full LXDE GREEN (nonbg 38026). Net: both the fcntl (libxcb) and FIONREAD (libX11) repayments now correctly target the -threads client trees. Proof gui-progress/2026-06-24T18/proof-repay-stocklibs-lxde.png. RULE: always edit + `build-xlib.sh <lib>-threads` for client-side X libs.
2929
2026-06-24T18 (loop iter27): CONSTRAINT-#5 REPAYMENT #4 — libxcb writev patch -> libc wrapper. libxcb-threads/src/xcb_conn.c restored to STOCK upstream writev() + `if(n<0 && errno==EAGAIN) return 1` (was a hand-rolled per-iovec send() loop because wasi-libc's writev fails on host_net fds). New platform override registry/native/patches/wasi-libc-overrides/writev_hostnet.c provides __wrap_writev: host_net fds (>=0x40000000) emit each iovec with send() and surface would-block as EAGAIN (host_net -1==would-block, errno otherwise unreliable); non-host_net fds delegate to __real_writev. Linked via -Wl,--wrap=writev on all 3 clients (build-openbox/lxpanel/pcmanfm); override_writev.o wired into libhostcompat.a (build-openbox.sh + build-libfm.sh). Rebuilt libxcb-threads + relinked clients. Full LXDE@800x600 GREEN (nonbg 38026), 0 failed-to-open, 0 LinkError. Proof gui-progress/2026-06-24T18/proof-repay-writev-lxde.png. NOTE: --wrap=writev is a global wrap on a hot path; override delegates non-host_net to __real_writev with zero logic to keep the common path safe. REMAINING per-lib patches (the structural set, need read/write/readv overrides or host-fd virtualization): libxtrans-threads Xtranssock.c read/readv/writev/write (in libX11-threads), libxcb/xcb_in.c recv errno, libxtrans abstract-socket/large-fd. Four repayments done (fb-export, libxcb fcntl, libX11 FIONREAD, libxcb writev).
3030
2026-06-24T19 (XU0 iter1): NEW MILESTONE XU0 (Xubuntu/Xfce D-Bus session bus) — user approved the pivot + the D-Bus TCB sign-off (dbus-daemon as an untrusted guest = no TCB expansion, same AF_UNIX kernel brokering as X clients). Deleted the M8 LXDE loop (fa6a3c0c, M8 green + 4 repayments committed), added the Xubuntu loop (dee609f6, 17,47). BUILT dbus 1.14.10 — dbus-daemon + dbus-send + dbus-monitor — to wasm32-wasip1-threads from UNMODIFIED upstream (scripts/build-dbus.sh), fpcast'd to runnable .wasm guests (596K/225K/223K); the daemon imports the host_net socket layer (net_socket/bind/listen/accept/poll). Platform fixes (constraint #5): (1) configure WITHOUT --allow-undefined so feature-detection is accurate (--allow-undefined made every link-test pass -> false Solaris getpeerucred -> <ucred.h> not found); link -lhostcompat for real socket-func detection, add --allow-undefined only at the final link. (2) setgroups() no-op stub in wasi-compat.c (dbus drops privs at startup). Daemon now starts past instantiation; surfaces /dev/null then /dev/urandom — the host-backed --exec fs lacks /dev devices, but the kernel VFS (device_layer.rs) provides working /dev/null+/dev/urandom for X-mode (kernel-VFS) guests. NEXT (XU0 acceptance): a multi-guest kernel-VFS harness (run dbus-daemon + dbus-send/dbus-monitor sharing the kernel socket table, like the X clients) for the method-call+signal round-trip. Build half DONE.
31+
2026-06-24T19 (XU0 iter2): PLATFORM FIX — synthesize the standard /dev character devices in the host-backed wasm fs. dbus-daemon opens /dev/null + /dev/urandom at startup; the threaded guests use the wasm.rs host-backed fs (NOT the kernel device_layer), and the runner's path_open routed non-O_CREAT opens to the default WASI path_open (ENOENT for /dev/*). FIX: (1) crates/execution/src/wasm.rs adds a WasmCharDevice table (Null/Zero/Full/Random) + open/read/write/close handling (null→EOF, zero→zeros, urandom/random→getrandom, writes discarded); (2) node_import_cache.rs path_open routes /dev/null|/dev/zero|/dev/full|/dev/random|/dev/urandom through openGuestFileForPathOpen→fsModule.openSync (my device handler) regardless of O_CREAT (asset 99->100). VERIFIED: dbus-daemon now gets PAST /dev/null + /dev/urandom (DEVDIAG confirmed both opens synthesized); next gap is the session.conf fixture (dbus reads $PREFIX/share/dbus-1/session.conf — stage via --vm-tree or --config-file). No regression: openbox+xclock still renders (0 failed-to-open, 730 decorated). General fix — any Linux guest needing /dev devices benefits.

0 commit comments

Comments
 (0)