Skip to content

Commit 5bf5901

Browse files
authored
fix(native-sidecar): fix guest wasm file access on js_bridge-backed root mounts (#1646)
1 parent c811392 commit 5bf5901

4 files changed

Lines changed: 650 additions & 11 deletions

File tree

crates/agentos-actor-plugin/src/persistence.rs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -392,18 +392,24 @@ async fn read_file(host: &HostCtx, path: &str) -> Result<String> {
392392
if entry.is_directory {
393393
bail!("EISDIR is a directory: {}", entry.path);
394394
}
395-
// Fetch the content BLOB only here (lookup_entry is metadata-only now).
395+
Ok(fetch_content(host, &entry.path).await?.unwrap_or_default())
396+
}
397+
398+
/// Fetch the content BLOB with a dedicated query. `lookup_entry` is
399+
/// metadata-only (`NULL AS content`), so every consumer that actually needs
400+
/// bytes (`read_file`, `pread`, `truncate`, `link`) must fetch them here —
401+
/// reading `FsEntry::content` off a lookup silently yields empty data.
402+
async fn fetch_content(host: &HostCtx, path: &str) -> Result<Option<String>> {
396403
let mut rows = query_rows(
397404
host,
398405
"SELECT content FROM agent_os_fs_entries WHERE path = ?",
399-
&[json!(entry.path)],
406+
&[json!(path)],
400407
)
401408
.await?;
402-
let content = match rows.first_mut() {
403-
Some(row) => optional_content_col(row, "content")?,
404-
None => None,
405-
};
406-
Ok(content.unwrap_or_default())
409+
match rows.first_mut() {
410+
Some(row) => optional_content_col(row, "content"),
411+
None => Ok(None),
412+
}
407413
}
408414

409415
// --- ctx-free helpers (copied verbatim from rivetkit-agent-os::persistence) ---
@@ -846,11 +852,12 @@ async fn link_entry(host: &HostCtx, old_path: String, new_path: String) -> Resul
846852
if entry.is_directory {
847853
bail!("EPERM cannot hard-link directory: {old_path}");
848854
}
855+
let content = fetch_content(host, &entry.path).await?;
849856
insert_entry(
850857
host,
851858
&new_path,
852859
false,
853-
entry.content,
860+
content,
854861
entry.mode,
855862
entry.size,
856863
entry.symlink_target,
@@ -896,7 +903,8 @@ async fn truncate_file(host: &HostCtx, path: &str, len: i64) -> Result<()> {
896903
if entry.is_directory {
897904
bail!("EISDIR is a directory: {}", entry.path);
898905
}
899-
let mut bytes = decode_content(entry.content.as_deref().unwrap_or_default())?;
906+
let content = fetch_content(host, &entry.path).await?;
907+
let mut bytes = decode_content(content.as_deref().unwrap_or_default())?;
900908
bytes.resize(len as usize, 0);
901909
let content = BASE64.encode(bytes);
902910
run_stmt(
@@ -921,7 +929,8 @@ async fn pread_file(host: &HostCtx, path: &str, offset: i64, len: i64) -> Result
921929
if entry.is_directory {
922930
bail!("EISDIR is a directory: {}", entry.path);
923931
}
924-
let bytes = decode_content(entry.content.as_deref().unwrap_or_default())?;
932+
let content = fetch_content(host, &entry.path).await?;
933+
let bytes = decode_content(content.as_deref().unwrap_or_default())?;
925934
let start = (offset as usize).min(bytes.len());
926935
let end = start.saturating_add(len as usize).min(bytes.len());
927936
Ok(BASE64.encode(&bytes[start..end]))

crates/agentos-actor-plugin/src/persistence_e2e.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,56 @@ async fn persistence_round_trips_fs_ops_against_real_sqlite() {
390390
"readDir should list hello.txt, got {names:?}"
391391
);
392392

393+
// pread must return the real byte range. Regression: pread used to
394+
// read `entry.content` off the metadata-only lookup (`NULL AS
395+
// content`), so every guest WASM read of a mount-backed root file
396+
// came back empty (`cat` printed nothing, `wc -c` reported 0).
397+
let pread = persistence::handle_fs_call(
398+
&host,
399+
"pread",
400+
&json!({ "path": path, "offset": 6, "len": 7 }),
401+
)
402+
.await
403+
.expect("pread");
404+
assert_eq!(
405+
pread,
406+
Some(JsonValue::String(BASE64.encode(&content[6..13]))),
407+
"pread must return the stored bytes, not the metadata-only view"
408+
);
409+
410+
// Hard link must copy real content (same metadata-only-lookup bug).
411+
let link_path = "/work/hello-link.txt";
412+
persistence::handle_fs_call(
413+
&host,
414+
"link",
415+
&json!({ "oldPath": path, "newPath": link_path }),
416+
)
417+
.await
418+
.expect("link");
419+
let link_read =
420+
persistence::handle_fs_call(&host, "readFile", &json!({ "path": link_path }))
421+
.await
422+
.expect("readFile link");
423+
assert_eq!(
424+
link_read,
425+
Some(JsonValue::String(content_b64.clone())),
426+
"hard link must carry the original content"
427+
);
428+
429+
// Truncate must preserve the retained prefix (it used to zero-fill
430+
// from the metadata-only empty content).
431+
persistence::handle_fs_call(&host, "truncate", &json!({ "path": path, "len": 5 }))
432+
.await
433+
.expect("truncate");
434+
let truncated = persistence::handle_fs_call(&host, "readFile", &json!({ "path": path }))
435+
.await
436+
.expect("readFile after truncate");
437+
assert_eq!(
438+
truncated,
439+
Some(JsonValue::String(BASE64.encode(&content[..5]))),
440+
"truncate must keep the retained bytes intact"
441+
);
442+
393443
// removeFile → exists is now false.
394444
persistence::handle_fs_call(&host, "removeFile", &json!({ "path": path }))
395445
.await

0 commit comments

Comments
 (0)