Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/execution/assets/runners/wasi-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
const __agentOSWasiErrnoNoent = 44;
const __agentOSWasiErrnoNosys = 52;
const __agentOSWasiErrnoNotdir = 54;
const __agentOSWasiErrnoNotempty = 55;
const __agentOSWasiErrnoPipe = 64;
const __agentOSWasiErrnoRofs = 69;
const __agentOSWasiErrnoNotcapable = 76;
Expand Down Expand Up @@ -799,6 +800,8 @@ if (typeof globalThis !== "undefined" && typeof globalThis.__agentOSWasiModule =
return __agentOSWasiErrnoNoent;
case "ENOTDIR":
return __agentOSWasiErrnoNotdir;
case "ENOTEMPTY":
return __agentOSWasiErrnoNotempty;
case "EEXIST":
return __agentOSWasiErrnoExist;
case "EINVAL":
Expand Down
102 changes: 98 additions & 4 deletions crates/native-sidecar/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9939,8 +9939,7 @@ pub(crate) fn sync_active_process_host_writes_to_kernel(
vm: &mut VmState,
) -> Result<(), SidecarError> {
if vm.root_filesystem_mode != RootFilesystemMode::ReadOnly {
let shadow_root = vm.cwd.clone();
sync_host_directory_tree_to_kernel(vm, &shadow_root, "/")?;
sync_vm_shadow_root_to_kernel(vm)?;
}

let normalized_vm_root = normalize_host_path(&vm.cwd);
Expand All @@ -9952,6 +9951,90 @@ pub(crate) fn sync_active_process_host_writes_to_kernel(
Ok(())
}

/// Syncs the VM shadow root into the kernel VFS and reconciles deletions.
///
/// The walk itself is additive (it copies shadow entries into the kernel), so
/// on its own it can never express a guest deletion performed directly on the
/// shadow tree — a removed file would be resurrected forever. To get real
/// Linux semantics the walk records every guest path it sees and diffs that
/// set against the previous walk's inventory: paths that were present in the
/// shadow before and are gone now are removed from the kernel VFS as well.
///
/// Deletion propagation is scoped to the guest-writable shadow region: it only
/// runs for the VM shadow root walk (never for external host roots), re-checks
/// the protected/mount skip list, never touches the POSIX bootstrap skeleton,
/// and removes directories non-recursively so a kernel directory that still
/// has kernel-only content (for example a mount point or a path that was never
/// materialized into the shadow) is left in place with a warning.
fn sync_vm_shadow_root_to_kernel(vm: &mut VmState) -> Result<(), SidecarError> {
let shadow_root = normalize_host_path(&vm.cwd);
if host_sync_root_is_filesystem_root(&shadow_root) {
tracing::warn!("skipping host shadow sync rooted at the host filesystem root");
return Ok(());
}
let mut synced_file_times = BTreeMap::new();
let mut inventory = BTreeSet::new();
sync_host_directory_tree_to_kernel_inner(
vm,
&shadow_root,
&shadow_root,
"/",
&mut synced_file_times,
Some(&mut inventory),
)?;
propagate_shadow_deletions_to_kernel(vm, &inventory);
vm.shadow_sync_inventory = inventory;
Ok(())
}

/// Removes kernel paths whose shadow copy disappeared since the last walk.
///
/// Best-effort by design: a failure to reconcile one stale path must not
/// poison the guest filesystem operation that triggered the sync, so failures
/// are surfaced as host-visible warnings instead of errors. Children sort
/// after their parents in the `BTreeSet`, so the reverse iteration removes
/// leaves before the directories that contain them.
fn propagate_shadow_deletions_to_kernel(vm: &mut VmState, current: &BTreeSet<String>) {
if vm.shadow_sync_inventory.is_empty() {
return;
}
let stale: Vec<String> = vm
.shadow_sync_inventory
.iter()
.rev()
.filter(|path| !current.contains(*path))
.cloned()
.collect();
for path in stale {
if path == "/"
|| should_skip_shadow_sync_path(vm, &path)
|| is_shadow_bootstrap_dir(&path)
{
continue;
}
let stat = match vm.kernel.lstat(&path) {
Ok(stat) => stat,
// Already gone (for example removed through the kernel-direct
// guest fs path, which mirrors deletions into the shadow itself).
Err(_) => continue,
};
let result = if stat.is_directory && !stat.is_symbolic_link {
vm.kernel.remove_dir(&path)
} else {
vm.kernel.remove_file(&path)
};
if let Err(error) = result {
if error.code() != "ENOENT" {
tracing::warn!(
path = %path,
error = %error,
"failed to propagate guest shadow deletion into the kernel VFS"
);
}
}
}
}

fn collect_active_process_host_sync_roots(
vm: &VmState,
normalized_vm_root: &Path,
Expand Down Expand Up @@ -9998,8 +10081,7 @@ fn sync_process_host_roots_to_kernel(
process_guest_cwd: &str,
) -> Result<(), SidecarError> {
if vm.root_filesystem_mode != RootFilesystemMode::ReadOnly {
let shadow_root = vm.cwd.clone();
sync_host_directory_tree_to_kernel(vm, &shadow_root, "/")?;
sync_vm_shadow_root_to_kernel(vm)?;
}

if !path_is_within_root(
Expand Down Expand Up @@ -10038,6 +10120,7 @@ fn sync_host_directory_tree_to_kernel(
&normalized_host_root,
&normalized_guest_root,
&mut synced_file_times,
None,
)
}

Expand All @@ -10047,6 +10130,7 @@ fn sync_host_directory_tree_to_kernel_inner(
current_host_dir: &Path,
guest_root: &str,
synced_file_times: &mut BTreeMap<(u64, u64), (u64, u64)>,
mut inventory: Option<&mut BTreeSet<String>>,
) -> Result<(), SidecarError> {
let entries = match fs::read_dir(current_host_dir) {
Ok(entries) => entries,
Expand Down Expand Up @@ -10108,6 +10192,15 @@ fn sync_host_directory_tree_to_kernel_inner(
continue;
}

// Record every shadow entry we can represent in the kernel (dirs,
// files, symlinks) so the deletion reconcile can tell "was in the
// shadow, now gone" apart from "never came from the shadow".
if let Some(inventory) = inventory.as_deref_mut() {
if file_type.is_dir() || file_type.is_file() || file_type.is_symlink() {
inventory.insert(guest_path.clone());
}
}

if file_type.is_dir() {
let metadata = entry.metadata().map_err(|error| {
SidecarError::Io(format!(
Expand Down Expand Up @@ -10143,6 +10236,7 @@ fn sync_host_directory_tree_to_kernel_inner(
&host_path,
guest_root,
synced_file_times,
inventory.as_deref_mut(),
)?;
continue;
}
Expand Down
9 changes: 9 additions & 0 deletions crates/native-sidecar/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,15 @@ pub(crate) struct VmState {
/// packages ship no `agentos-package.json`, so agent enumeration and
/// resolution read this instead of the guest filesystem.
pub(crate) projected_agent_launch: BTreeMap<String, ProjectedAgentLaunch>,
/// Guest paths that were present in the VM shadow root during the last
/// shadow->kernel sync walk. The next walk diffs the current shadow tree
/// against this set so guest deletions performed directly on the shadow
/// (host-side runtimes, WASI passthrough writes) propagate into the kernel
/// VFS instead of being resurrected by the otherwise additive sync.
/// Memory is bounded by the shadow tree itself, which is capped by the
/// kernel filesystem inode/byte resource limits that bound what the walk
/// can materialize.
pub(crate) shadow_sync_inventory: BTreeSet<String>,
}

/// Launch parameters for one projected agent package.
Expand Down
1 change: 1 addition & 0 deletions crates/native-sidecar/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ where
signal_states: BTreeMap::new(),
packages_staging_root: None,
projected_agent_launch: BTreeMap::new(),
shadow_sync_inventory: BTreeSet::new(),
},
);

Expand Down
165 changes: 165 additions & 0 deletions crates/native-sidecar/tests/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,171 @@ mod shadow_root {
}
}

fn base_guest_filesystem_request(
operation: GuestFilesystemOperation,
path: &str,
) -> GuestFilesystemCallRequest {
GuestFilesystemCallRequest {
operation,
path: String::from(path),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
max_depth: None,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
}

fn guest_path_exists(
sidecar: &mut agentos_native_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
request_id: i64,
path: &str,
) -> bool {
let response = sidecar
.dispatch_wire_blocking(wire_request(
request_id,
wire_vm(connection_id, session_id, vm_id),
RequestPayload::GuestFilesystemCallRequest(base_guest_filesystem_request(
GuestFilesystemOperation::Exists,
path,
)),
))
.expect("dispatch guest filesystem exists");
match response.response.payload {
ResponsePayload::GuestFilesystemResultResponse(result) => {
result.exists.unwrap_or(false)
}
other => panic!("expected guest_filesystem_result response, got {other:?}"),
}
}

/// Deleting a path directly from the VM shadow root (the way host-side
/// guest runtimes delete files, without a kernel-direct unlink) must
/// propagate into the kernel VFS on the next shadow sync walk instead of
/// being resurrected by the additive copy-in.
#[test]
fn shadow_direct_deletions_propagate_into_kernel_vfs() {
let mut sidecar = create_test_sidecar();
let (connection_id, session_id) = authenticate_and_open_session(&mut sidecar);
// Guest filesystem calls need no command mounts; create the VM bare so
// this regression test runs even without built registry commands.
let cwd = temp_dir("filesystem-shadow-reconcile-cwd");
let (vm_id, _) = create_vm_wire(
&mut sidecar,
3,
&connection_id,
&session_id,
GuestRuntimeKind::JavaScript,
&cwd,
);

let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos();
let doomed_root = format!("/workspace/reconcile-{nonce}");
let doomed_dir = format!("{doomed_root}/nested");
let doomed_file = format!("{doomed_dir}/probe.txt");
let survivor_file = format!("/workspace/reconcile-survivor-{nonce}.txt");

let mut mkdir =
base_guest_filesystem_request(GuestFilesystemOperation::CreateDir, &doomed_dir);
mkdir.recursive = true;
guest_filesystem_call(&mut sidecar, &connection_id, &session_id, &vm_id, 20, mkdir);

let mut write =
base_guest_filesystem_request(GuestFilesystemOperation::WriteFile, &doomed_file);
write.content = Some(String::from("doomed\n"));
write.encoding = Some(RootFilesystemEntryEncoding::Utf8);
guest_filesystem_call(&mut sidecar, &connection_id, &session_id, &vm_id, 21, write);

let mut survivor =
base_guest_filesystem_request(GuestFilesystemOperation::WriteFile, &survivor_file);
survivor.content = Some(String::from("survivor\n"));
survivor.encoding = Some(RootFilesystemEntryEncoding::Utf8);
guest_filesystem_call(&mut sidecar, &connection_id, &session_id, &vm_id, 22, survivor);

// This read-side call walks the shadow tree and records the inventory
// the deletion reconcile diffs against.
assert!(guest_path_exists(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
23,
&doomed_file,
));

// Locate this VM's shadow root through the mirrored unique file, then
// delete the subtree exactly as a host-side guest runtime would: on
// the shadow filesystem only, with no kernel-direct unlink.
let marker_rel = format!(
"workspace/reconcile-{nonce}/nested/probe.txt"
);
let shadow_root = std::fs::read_dir(std::env::temp_dir())
.expect("list temp dir")
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.find(|candidate| {
candidate
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("agentos-native-sidecar-shadow-"))
&& candidate.join(&marker_rel).is_file()
})
.expect("locate VM shadow root through mirrored probe file");
fs::remove_dir_all(shadow_root.join(format!("workspace/reconcile-{nonce}")))
.expect("delete subtree from the shadow root");

// The next host filesystem call re-walks the shadow; the kernel must
// drop the deleted subtree instead of resurrecting it forever.
assert!(
!guest_path_exists(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
24,
&doomed_file,
),
"kernel resurrected a file deleted from the shadow root"
);
assert!(
!guest_path_exists(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
25,
&doomed_root,
),
"kernel resurrected a directory deleted from the shadow root"
);
assert!(
guest_path_exists(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
26,
&survivor_file,
),
"deletion reconcile must not remove shadow-backed paths that still exist"
);

dispose_vm_and_close_session(&mut sidecar, &connection_id, &session_id, &vm_id);
}

#[allow(clippy::too_many_arguments)]
fn execute_command(
sidecar: &mut agentos_native_sidecar::NativeSidecar<RecordingBridge>,
Expand Down
Loading