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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ by default, warn near threshold, and fail with a typed error that names the
limit and how to raise it. Host-visible warnings/errors must reach stderr/log
or structured trace paths, not stay trapped in the VM.

Never swallow errors silently. Every failure must either propagate as a hard,
typed error to the caller (preferred) or be clearly logged at the failure site;
empty `catch`/`let _ =` on fallible operations and fire-and-forget promises
that drop rejections are bugs, not defensive coding. For guest-visible
surfaces, prefer matching Linux behavior — the correct POSIX errno delivered to
the guest — over inventing a softer fallback that hides the failure.

## Runtime And Registry

- The projected `/opt/agentos` filesystem is the source of truth for software
Expand Down
2 changes: 1 addition & 1 deletion examples/filesystem/mount-custom-vfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { AgentOs, createInMemoryFileSystem } from "@rivet-dev/agentos-core";
const vm = await AgentOs.create({ defaultSoftware: false });
const driver = createInMemoryFileSystem();

vm.mountFs("/home/agentos/scratch", driver);
await vm.mountFs("/home/agentos/scratch", driver);
await vm.writeFile("/home/agentos/scratch/hello.txt", "hello");
26 changes: 21 additions & 5 deletions packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2821,6 +2821,12 @@ export class AgentOs {
permissions: sidecarPermissions,
commandPermissions: {},
loopbackExemptPorts: options?.loopbackExemptPorts,
// Retained for runtime mount reconfigures: `configure_vm` is
// replace-on-write for the whole payload, so post-boot mountFs
// must resend the boot packages and tool shims.
packages: sidecarPackages,
packagesMountAt: OPT_AGENTOS_ROOT,
toolShimCommands: toolBootstrapCommands,
commandGuestPaths,
onDispose: cleanup,
// The native process is owned by the AgentOsSidecar handle and
Expand Down Expand Up @@ -3238,18 +3244,24 @@ export class AgentOs {
);
}

mountFs(
/**
* Mount a filesystem into the running VM. Resolves once the mount has been
* delivered to the native sidecar, so guest code can use it immediately
* after the returned promise settles; a delivery failure rejects instead of
* leaving the mount silently host-only.
*/
async mountFs(
path: string,
driver: VirtualFileSystem,
options?: { readOnly?: boolean },
): void {
): Promise<void> {
this._assertSafeAbsolutePath(path);
this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
await this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
}

unmountFs(path: string): void {
async unmountFs(path: string): Promise<void> {
this._assertSafeAbsolutePath(path);
this.#kernel.unmountFs(path);
await this.#kernel.unmountFs(path);
}

async move(from: string, to: string): Promise<void> {
Expand Down Expand Up @@ -3541,6 +3553,10 @@ export class AgentOs {
]),
),
);
// Retain the linked package for runtime mount reconfigures:
// `configure_vm` is replace-on-write, so a later `mountFs` that
// resent only the boot packages would unproject this one.
this.#kernel.registerLinkedPackage({ path: ref.path });
}
// The client parses no manifests: an `agent` block in the linked package is
// picked up by the sidecar (it owns the projected `/opt/agentos` and answers
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/runtime-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,8 @@ export interface Kernel extends KernelInterface {
path: string,
fs: VirtualFileSystem,
options?: { readOnly?: boolean },
): void;
unmountFs(path: string): void;
): void | Promise<void>;
unmountFs(path: string): void | Promise<void>;
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, content: string | Uint8Array): Promise<void>;
mkdir(path: string): Promise<void>;
Expand Down Expand Up @@ -2674,7 +2674,7 @@ class NativeKernel implements Kernel {
mountPath: string,
filesystem: VirtualFileSystem,
options?: { readOnly?: boolean },
): void {
): void | Promise<void> {
const localMount = makeLocalCompatMount({
path: mountPath,
fs: filesystem,
Expand All @@ -2685,23 +2685,24 @@ class NativeKernel implements Kernel {
(left, right) => right.path.length - left.path.length,
);
if (!this.proxy) {
// Pre-boot mounts apply during kernel initialization.
return;
}
this.proxy.mountFs(mountPath, filesystem, {
return this.proxy.mountFs(mountPath, filesystem, {
readOnly: localMount.readOnly,
sidecarMount: localMount.sidecarMount,
});
}

unmountFs(mountPath: string): void {
unmountFs(mountPath: string): void | Promise<void> {
const normalized = normalizePath(mountPath);
const pendingIndex = this.pendingLocalMounts.findIndex(
(mount) => mount.path === normalized,
);
if (pendingIndex >= 0) {
this.pendingLocalMounts.splice(pendingIndex, 1);
}
this.proxy?.unmountFs(mountPath);
return this.proxy?.unmountFs(mountPath);
}

async readFile(targetPath: string): Promise<Uint8Array> {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,8 @@ export interface Kernel extends KernelInterface {
path: string,
fs: VirtualFileSystem,
options?: { readOnly?: boolean },
): void;
unmountFs(path: string): void;
): void | Promise<void>;
unmountFs(path: string): void | Promise<void>;
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, content: string | Uint8Array): Promise<void>;
mkdir(path: string): Promise<void>;
Expand Down
60 changes: 54 additions & 6 deletions packages/core/src/sidecar/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ interface NativeSidecarKernelProxyOptions {
SidecarProcess["configureVm"]
>[2]["commandPermissions"];
loopbackExemptPorts?: number[];
/**
* The boot `configureVm` payload pieces beyond mounts/permissions. Rust
* `configure_vm` rebuilds the whole VM configuration from each payload, so
* every runtime mount reconfigure must resend these or a post-boot
* `mountFs()` silently drops the `/opt/agentos` package projections and
* tool shim commands applied at boot.
*/
packages?: Parameters<SidecarProcess["configureVm"]>[2]["packages"];
packagesMountAt?: string;
toolShimCommands?: string[];
commandGuestPaths: ReadonlyMap<string, string>;
onWasmCommandResolved?: (command: string) => void;
onDispose?: () => Promise<void>;
Expand Down Expand Up @@ -371,6 +381,13 @@ export class NativeSidecarKernelProxy {
| Parameters<SidecarProcess["configureVm"]>[2]["commandPermissions"]
| undefined;
private readonly loopbackExemptPorts: number[] | undefined;
// Mutable: runtime `linkSoftware` appends via `registerLinkedPackage` so
// later mount reconfigures resend linked packages too, not just boot ones.
private packages: NonNullable<
Parameters<SidecarProcess["configureVm"]>[2]["packages"]
>;
private readonly packagesMountAt: string | undefined;
private readonly toolShimCommands: string[] | undefined;
private readonly commandDrivers: Map<string, string>;
private readonly onWasmCommandResolved:
| ((command: string) => void)
Expand Down Expand Up @@ -420,6 +437,9 @@ export class NativeSidecarKernelProxy {
this.permissions = options.permissions;
this.commandPermissions = options.commandPermissions;
this.loopbackExemptPorts = options.loopbackExemptPorts;
this.packages = options.packages ? [...options.packages] : [];
this.packagesMountAt = options.packagesMountAt;
this.toolShimCommands = options.toolShimCommands;
this.commandDrivers = buildCommandMap(options.commandGuestPaths);
this.onWasmCommandResolved = options.onWasmCommandResolved;
this.onDispose = options.onDispose;
Expand Down Expand Up @@ -449,6 +469,18 @@ export class NativeSidecarKernelProxy {
}
}

/**
* Record a runtime-linked package (`linkSoftware`) so mount reconfigures
* resend it. Rust `configure_vm` rebuilds the whole VM configuration from
* each payload, so a linked package omitted here would be silently
* unprojected from `/opt/agentos` by the next `mountFs`/`unmountFs`.
*/
registerLinkedPackage(descriptor: { path: string }): void {
if (!this.packages.some((pkg) => pkg.path === descriptor.path)) {
this.packages.push({ path: descriptor.path });
}
}

async dispose(): Promise<void> {
if (this.disposed) {
return;
Expand Down Expand Up @@ -1504,7 +1536,7 @@ export class NativeSidecarKernelProxy {
path: string,
driver: VirtualFileSystem,
options?: { readOnly?: boolean; sidecarMount?: SidecarMountDescriptor },
): void {
): Promise<void> {
this.localMounts.unshift({
path: posixPath.normalize(path),
fs: driver,
Expand All @@ -1514,18 +1546,27 @@ export class NativeSidecarKernelProxy {
this.localMounts.sort(
(left, right) => right.path.length - left.path.length,
);
void this.reconfigureSidecarMounts().catch(() => {});
// Resolves once the sidecar has the mount; a swallowed rejection here
// used to turn reconfigure failures into silently missing guest mounts.
// The local catch only guards callers that drop the promise — awaiting
// callers still observe the rejection.
const applied = this.reconfigureSidecarMounts();
applied.catch(() => {});
return applied;
}

unmountFs(path: string): void {
unmountFs(path: string): Promise<void> {
const normalized = posixPath.normalize(path);
const index = this.localMounts.findIndex(
(mount) => mount.path === normalized,
);
if (index >= 0) {
this.localMounts.splice(index, 1);
void this.reconfigureSidecarMounts().catch(() => {});
if (index < 0) {
return Promise.resolve();
}
this.localMounts.splice(index, 1);
const applied = this.reconfigureSidecarMounts();
applied.catch(() => {});
return applied;
}

private desiredSidecarMounts(): SidecarMountDescriptor[] {
Expand All @@ -1550,11 +1591,18 @@ export class NativeSidecarKernelProxy {
if (this.disposed) {
return;
}
// Rust `configure_vm` rebuilds the whole VM configuration from this
// payload, so resend the boot packages / tool shim commands too —
// omitting them here strips the `/opt/agentos` projections and tool
// shims from the VM as a side effect of a runtime mount change.
await this.client.configureVm(this.session, this.vm, {
mounts: this.desiredSidecarMounts(),
permissions: this.permissions,
commandPermissions: this.commandPermissions,
loopbackExemptPorts: this.loopbackExemptPorts,
packages: this.packages,
packagesMountAt: this.packagesMountAt,
toolShimCommands: this.toolShimCommands,
});
};
const previous = this.mountReconfigurePromise ?? Promise.resolve();
Expand Down
Loading