From 40664e488e97ffa83f73cfe6926322539d00e6ee Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 5 Jul 2026 22:04:12 -0700 Subject: [PATCH] fix(vfs): resolve content ops through single-symlink leaf mounts - read_file/stat/pread follow the tar-vfs `/current -> ` symlink across the mount boundary, gated to single-symlink-root leaf mounts so normal mounts keep their native error semantics (e.g. js_bridge ENOENT/EIO) - toolchain pack reads the source agentos-package.json from the installed package dir so the packed tar carries the canonical unscoped agent name - registry agent packages ship agentos-package.json in `files` --- CLAUDE.md | 1 + crates/vfs/src/posix/mount_table.rs | 57 ++++++++++++++++++++++++-- packages/agentos-toolchain/src/pack.ts | 9 +++- registry/agent/claude/package.json | 3 +- registry/agent/opencode/package.json | 3 +- registry/agent/pi-cli/package.json | 3 +- registry/agent/pi/package.json | 3 +- 7 files changed, 71 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 656800000..237d5efed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ Every bound that protects a shared resource — memory/heap, CPU/wall-clock, fd/ - **No serialize→deserialize in-process.** Pass the typed struct directly; wire encoding is for the wire only. Don't encode a frame to bytes only to re-parse it into a command. - **No whole-buffer copies per I/O.** Use chunked `Vec` + `extend_from_slice`, not byte-by-byte fills; move/`Arc`/slice payloads — never clone a record that carries its full buffer on each read/write. - **Zero host-disk writes on VM start (mount, don't extract).** Mounting read-only packaged content (the `agentos_packages` tar projection, or any archive/content mount) MUST NOT extract the archive to host disk or write physical symlinks at VM/session start. Back content by byte-range reads over the source archive (mmap/seek into the uncompressed tar, digest-keyed and shared across VMs), and serve derived symlinks (the `/opt/agentos/bin` farm, `/current`) as **in-memory synthetic mounts**, never on-disk nodes. Extraction/staging is redundant work + state (full unpack, ~32k inodes, symlink farm, temp cleanup, double on-disk copy) for bytes the archive already holds at a known offset — reintroducing it is the bug. The only writes VM start may incur are cheap in-RAM overlay mountpoint dirs materialized by the mount table; host-disk materialization is zero. Any code that "helpfully" extracts must be reverted; the tar reader and projection carry doc comments saying why. +- **Content ops MUST follow symlinks ACROSS mounts (the projection is composed of granular leaf mounts).** `/opt/agentos` is built from many leaf mounts — a tar mount per `pkgs//`, and a single-symlink leaf mount per `pkgs//current` and per `bin/`. So `/current` and `` are symlinks whose targets live in a *different* mount. `MountTable::resolve_index` is purely lexical, so every content op (`read_file`/`stat`/`pread`) and `exec` MUST resolve the path through `realpath` (cross-mount symlink walk) before routing — reading `/opt/agentos/pkgs//current/agentos-package.json` has to follow `current → ` into the tar mount. Two invariants make this work and must not regress: (a) content ops route via `resolve_content_index` (realpath-then-route), not raw `resolve_index`; (b) `MountTable::realpath`'s single-mount delegation falls back to the component walk on **ENOENT as well as ELOOP** — a single-symlink leaf mount returns ENOENT for any descendant path (its root IS the symlink), and only the walk can follow it. This is why runtime agent/package resolution can read the live FS by name (`/opt/agentos/pkgs//current/...`) even though `` was installed as separate leaf mounts, and why dynamically-linked packages resolve without re-reading the client's `packages` list. - **No per-call allocs/locks/clones** on the sync hot path. - **Avoid polling**, prefer readiness/event-driven. But a read-probe can be load-bearing for protocol correctness — measure before removing one, and keep its semantic test. - **No baseline, no merge.** Capture native + unoptimized numbers BEFORE touching code, gate every change on a measured before/after delta, and keep it measure-gated. diff --git a/crates/vfs/src/posix/mount_table.rs b/crates/vfs/src/posix/mount_table.rs index 9770e7b38..9f556970a 100644 --- a/crates/vfs/src/posix/mount_table.rs +++ b/crates/vfs/src/posix/mount_table.rs @@ -839,6 +839,46 @@ impl MountTable { )) } + /// Resolve a path for a CONTENT operation (read_file/stat/pread/read_dir) + /// that must follow symlinks like POSIX `open()`. `resolve_index` is purely + /// lexical, so a path that descends through a symlink whose target lives in a + /// *different* mount (e.g. `/opt/agentos/pkgs//current -> `, + /// where `current` is its own single-symlink leaf mount) would route into the + /// symlink mount and fail. `realpath` follows those cross-mount symlinks, so + /// resolve it first, then route the resolved path. Falls back to the raw path + /// when realpath can't resolve it (e.g. a genuinely missing file) so callers + /// still receive the mount's own ENOENT. + /// True only for the tar-vfs single-symlink-root leaf mount (`/current -> + /// ` and the `bin/` links), whose root inode IS a symlink. Only + /// these mounts need cross-mount realpath resolution for content ops and the + /// ENOENT->component-walk fallback; every other mount serves its own paths and + /// MUST keep its native error semantics (e.g. a js_bridge mount's ENOENT/EIO), + /// so gating on the concrete leaf type leaves normal mounts untouched. + fn mount_is_symlink_leaf(&self, index: usize) -> bool { + // Behavioral check (robust through the ReadOnly/MountedVirtual wrappers the + // projection applies): a leaf whose root inode is itself a symbolic link. + // Only the tar-vfs `/current -> ` and `bin/` single-symlink + // mounts satisfy this; every normal mount's root is a directory. + self.mounts[index] + .filesystem + .lstat("/") + .map(|stat| stat.is_symbolic_link) + .unwrap_or(false) + } + + fn resolve_content_index(&self, path: &str) -> VfsResult<(usize, String)> { + let raw = self.resolve_index(&normalize_path(path))?; + if !self.mount_is_symlink_leaf(raw.0) { + return Ok(raw); + } + // The leaf's root is a symlink, so a descendant path must be followed across + // the mount boundary to the real version tree before the content op runs. + match self.realpath(path) { + Ok(resolved) => self.resolve_index(&resolved), + Err(_) => Ok(raw), + } + } + fn child_mount_basenames(&self, path: &str) -> Vec { let normalized = normalize_path(path); let mut basenames = BTreeSet::new(); @@ -918,7 +958,7 @@ impl Drop for MountTable { impl VirtualFileSystem for MountTable { fn read_file(&mut self, path: &str) -> VfsResult> { - let (index, relative_path) = self.resolve_index(path)?; + let (index, relative_path) = self.resolve_content_index(path)?; self.mounts[index].filesystem.read_file(&relative_path) } @@ -1068,7 +1108,7 @@ impl VirtualFileSystem for MountTable { } fn stat(&mut self, path: &str) -> VfsResult { - let (index, relative_path) = self.resolve_index(path)?; + let (index, relative_path) = self.resolve_content_index(path)?; self.mounts[index].filesystem.stat(&relative_path) } @@ -1102,7 +1142,18 @@ impl VirtualFileSystem for MountTable { let (index, relative_path) = self.resolve_index(&normalized)?; match self.realpath_in_mount(index, &relative_path) { Ok(resolved) => return Ok(resolved), + // Always fall back to the component walk on ELOOP. Fall back on ENOENT + // ONLY for a single-symlink LEAF mount (e.g. `/current -> `): + // it cannot resolve a descendant path itself — its root IS the symlink, so + // a subpath resolves into it and ENOENTs; the walk then follows that + // symlink across mounts. For every OTHER mount an ENOENT is a genuine + // "missing file" that must propagate unchanged (walking it would rewrite a + // mount's native ENOENT into a spurious success/EIO — see the js_bridge + // errno-mapping mount). Non-leaf within-mount paths resolve via + // `realpath_in_mount` above and return early, so pnpm resolution is + // unaffected either way. Err(error) if error.code() == "ELOOP" => {} + Err(error) if error.code() == "ENOENT" && self.mount_is_symlink_leaf(index) => {} Err(error) => return Err(error), } @@ -1237,7 +1288,7 @@ impl VirtualFileSystem for MountTable { } fn pread(&mut self, path: &str, offset: u64, length: usize) -> VfsResult> { - let (index, relative_path) = self.resolve_index(path)?; + let (index, relative_path) = self.resolve_content_index(path)?; self.mounts[index] .filesystem .pread(&relative_path, offset, length) diff --git a/packages/agentos-toolchain/src/pack.ts b/packages/agentos-toolchain/src/pack.ts index f56d26223..85e1ff8db 100644 --- a/packages/agentos-toolchain/src/pack.ts +++ b/packages/agentos-toolchain/src/pack.ts @@ -300,11 +300,18 @@ export function pack(options: PackOptions): PackResult { const { source, out, agent, pruneNative } = options; const tmp = mkdtempSync(join(tmpdir(), "agentos-pack-")); try { - const sourceManifest = readAgentosPackageManifest(source); npmInstallFlat(resolveInstallSpec(source, tmp), tmp); const name = installedPackageName(source); const installedDir = join(tmp, "node_modules", name); + // Read the source `agentos-package.json` from the INSTALLED package dir, not + // the source spec: for npm specs (`@scope/name`) the spec is not a directory, + // so reading it yields nothing and the packed name falls back to the unscoped + // npm name — which is only coincidentally correct (e.g. `@agentos-software/pi` + // -> `pi`) and wrong when they differ (`@agentos-software/claude-code` -> + // `claude-code`, but the agent id is `claude`). The installed manifest carries + // the canonical unscoped agent `name` the sidecar projects/resolves on. + const sourceManifest = readAgentosPackageManifest(installedDir); const pkg = JSON.parse(readFileSync(join(installedDir, "package.json"), "utf8")); const version: string = pkg.version; const bins = binEntries(installedDir); diff --git a/registry/agent/claude/package.json b/registry/agent/claude/package.json index 08504792a..44f2b2d3f 100644 --- a/registry/agent/claude/package.json +++ b/registry/agent/claude/package.json @@ -17,7 +17,8 @@ } }, "files": [ - "dist" + "dist", + "agentos-package.json" ], "scripts": { "build": "tsc && node ./scripts/build-patched-cli.mjs && rm -rf dist/package && agentos-toolchain pack . --out dist/package --agent claude-sdk-acp --prune-native", diff --git a/registry/agent/opencode/package.json b/registry/agent/opencode/package.json index ddcbcd0ce..7716c0fa9 100644 --- a/registry/agent/opencode/package.json +++ b/registry/agent/opencode/package.json @@ -16,7 +16,8 @@ } }, "files": [ - "dist" + "dist", + "agentos-package.json" ], "scripts": { "build": "node ./scripts/build-opencode-acp.mjs && tsc && agentos-toolchain pack . --out dist/package --agent agentos-opencode-acp", diff --git a/registry/agent/pi-cli/package.json b/registry/agent/pi-cli/package.json index 6775ac136..746199345 100644 --- a/registry/agent/pi-cli/package.json +++ b/registry/agent/pi-cli/package.json @@ -17,7 +17,8 @@ } }, "files": [ - "dist" + "dist", + "agentos-package.json" ], "scripts": { "build": "tsc && agentos-toolchain pack . --out dist/package --agent pi-acp --prune-native", diff --git a/registry/agent/pi/package.json b/registry/agent/pi/package.json index 38cf6aca3..7a104f70e 100644 --- a/registry/agent/pi/package.json +++ b/registry/agent/pi/package.json @@ -17,7 +17,8 @@ } }, "files": [ - "dist" + "dist", + "agentos-package.json" ], "scripts": { "build": "tsc && node scripts/build-snapshot-bundle.mjs && agentos-toolchain pack . --out dist/package --agent pi-sdk-acp --prune-native && node scripts/copy-snapshot-into-package.mjs",