Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>` + `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, `<pkg>/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/<pkg>/<version>`, and a single-symlink leaf mount per `pkgs/<pkg>/current` and per `bin/<cmd>`. So `<pkg>/current` and `<cmd>` 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/<pkg>/current/agentos-package.json` has to follow `current → <version>` 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/<name>/current/...`) even though `<name>` 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.
Expand Down
57 changes: 54 additions & 3 deletions crates/vfs/src/posix/mount_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>/current -> <version>`,
/// 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 (`<pkg>/current ->
/// <version>` and the `bin/<cmd>` 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 `<pkg>/current -> <version>` and `bin/<cmd>` 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<String> {
let normalized = normalize_path(path);
let mut basenames = BTreeSet::new();
Expand Down Expand Up @@ -918,7 +958,7 @@ impl Drop for MountTable {

impl VirtualFileSystem for MountTable {
fn read_file(&mut self, path: &str) -> VfsResult<Vec<u8>> {
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)
}

Expand Down Expand Up @@ -1068,7 +1108,7 @@ impl VirtualFileSystem for MountTable {
}

fn stat(&mut self, path: &str) -> VfsResult<VirtualStat> {
let (index, relative_path) = self.resolve_index(path)?;
let (index, relative_path) = self.resolve_content_index(path)?;
self.mounts[index].filesystem.stat(&relative_path)
}

Expand Down Expand Up @@ -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. `<pkg>/current -> <version>`):
// 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),
}

Expand Down Expand Up @@ -1237,7 +1288,7 @@ impl VirtualFileSystem for MountTable {
}

fn pread(&mut self, path: &str, offset: u64, length: usize) -> VfsResult<Vec<u8>> {
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)
Expand Down
9 changes: 8 additions & 1 deletion packages/agentos-toolchain/src/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion registry/agent/claude/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion registry/agent/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion registry/agent/pi-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
}
},
"files": [
"dist"
"dist",
"agentos-package.json"
],
"scripts": {
"build": "tsc && agentos-toolchain pack . --out dist/package --agent pi-acp --prune-native",
Expand Down
3 changes: 2 additions & 1 deletion registry/agent/pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading