diff --git a/docs/content/docs/host-functions.mdx b/docs/content/docs/host-functions.mdx
index 697e979405..b5d6683f90 100644
--- a/docs/content/docs/host-functions.mdx
+++ b/docs/content/docs/host-functions.mdx
@@ -110,6 +110,11 @@ Host functions run on the host with full access to the host environment, so do n
Pass the same host-function collections to `AgentOs.create()`. The function contract and
generated VM commands are unchanged.
+VM-scoped host-function and filesystem-bridge callbacks use the originating
+VM's ownership. Each VM retains its own callback handler on a shared sidecar.
+Callbacks for an unknown or removed VM are rejected. A VM's handler is removed
+after its teardown hooks finish, so disposing one VM preserves its siblings.
+
Read more in the [embedded API quickstart](/agentos/docs/quickstart-embedded).
diff --git a/docs/content/docs/resource-limits.mdx b/docs/content/docs/resource-limits.mdx
index 9cf2ac0432..3410310a1a 100644
--- a/docs/content/docs/resource-limits.mdx
+++ b/docs/content/docs/resource-limits.mdx
@@ -15,6 +15,15 @@ sets the live execution count that triggers a warning, not a rejection.
These fields are optional in both Rust and TypeScript Core; omission keeps the
sidecar default.
+The TypeScript runtime-core transport retains at most 4,096 VM callback
+handlers per connection. Embedded sidecar users can raise this local bound
+with `SidecarSpawnOptions.maxVmRequestHandlers`; direct protocol clients use
+`SidecarProtocolClientOptions.maxVmRequestHandlers`. The SDK writes a host
+console warning at 80% capacity and rejects a new handler with
+`SidecarVmRequestHandlerLimit` when full. Replacing a handler keeps the same
+slot, and removing it releases capacity. This transport-local option does not
+change the native VM admission or executor policy.
+
Process and terminal output replay is also sidecar-owned. The defaults retain
up to 1,024 events or 1 MiB for each opted-in process, return at most 256 events
or 768 KiB per read, and retain at most 1,024 replay records per VM. Override
diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts
index a9c4d782c2..f04fde925d 100644
--- a/packages/core/src/agent-os.ts
+++ b/packages/core/src/agent-os.ts
@@ -12,13 +12,6 @@ import type {
MountConfigJsonValue,
NativeMountPluginDescriptor,
} from "@rivet-dev/agentos-runtime-core/descriptors";
-import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol";
-import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads";
-import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors";
-import type {
- CreateVmConfig,
- VmUserConfig,
-} from "@rivet-dev/agentos-runtime-core/vm-config";
import {
type HostFunction,
type HostFunctionCollections,
@@ -28,6 +21,13 @@ import {
resolveHostFunctions,
} from "@rivet-dev/agentos-runtime-core/host-functions";
import { zodToJsonSchema } from "@rivet-dev/agentos-runtime-core/host-functions-zod";
+import * as executionProtocol from "@rivet-dev/agentos-runtime-core/protocol";
+import type { LivePackageAcquisitionSource } from "@rivet-dev/agentos-runtime-core/request-payloads";
+import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors";
+import type {
+ CreateVmConfig,
+ VmUserConfig,
+} from "@rivet-dev/agentos-runtime-core/vm-config";
import type {
CodeEvaluationResult,
CodeExecutionResult,
@@ -54,6 +54,12 @@ import type {
TypeScriptFileExecutionOptions,
} from "./language-execution.js";
import { parseAgentOsOptions } from "./options-schema.js";
+import {
+ type ReplayReadOptions,
+ replayPage,
+ replayPageLimits,
+ replayWirePageLimits,
+} from "./output-replay.js";
import { buildProcessForest } from "./process-forest.js";
import type {
ConnectTerminalOptions,
@@ -75,12 +81,6 @@ import {
resolveSandboxOptions,
} from "./sandbox.js";
import { resolvePublishedSidecarBinary } from "./sidecar/binary.js";
-import {
- replayPage,
- replayPageLimits,
- replayWirePageLimits,
- type ReplayReadOptions,
-} from "./output-replay.js";
import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js";
export type {
@@ -5633,7 +5633,7 @@ export class AgentOs {
},
};
}
- });
+ }, this._sidecarVm.vmId);
}
// ── Cron ────────────────────────────────────────────────────
@@ -5733,6 +5733,7 @@ export class AgentOs {
result.status === "rejected" ? [result.reason] : [],
),
);
+ this._sidecarClient.setSidecarRequestHandler(null, this._sidecarVm.vmId);
if (errors.length === 1) throw errors[0];
if (errors.length > 1) {
throw new AggregateError(errors, "AgentOS VM disposal failed");
diff --git a/packages/core/tests/host-function-permissions.test.ts b/packages/core/tests/host-function-permissions.test.ts
index ae1fbccb65..d360a17998 100644
--- a/packages/core/tests/host-function-permissions.test.ts
+++ b/packages/core/tests/host-function-permissions.test.ts
@@ -22,12 +22,13 @@ async function createVmCapturingHandler(
.mockImplementation(function (
this: NativeSidecarProcessClient,
handler: any,
+ vmId?: string,
) {
if (handler) {
captured = handler as CapturedHandler;
}
// Still install on the real client so the VM behaves normally.
- return original.call(this, handler);
+ return original.call(this, handler, vmId);
});
try {
const vm = await AgentOs.create(options);
@@ -410,3 +411,56 @@ describe("host-function collection permissions: raw host_callback RPC path", ()
expect(response.error).toMatch(/invalid registry callback/i);
});
});
+
+describe("host functions on a shared sidecar", () => {
+ test("keeps each VM's callbacks after a sibling is disposed or has no host functions", async () => {
+ const sidecar = await AgentOs.createSidecar();
+ const vms: AgentOs[] = [];
+ const permissions = {
+ fs: "allow",
+ childProcess: "allow",
+ hostFunction: "allow",
+ } as const;
+ const invoke = async (vm: AgentOs, id: string) => {
+ const result = await runCommand(vm, "agentos-identity", ["who"]);
+ expect(result.exitCode, result.stderr).toBe(0);
+ expect(JSON.parse(result.stdout)).toEqual({ ok: true, result: { id } });
+ };
+ try {
+ for (const id of ["a", "b", "c"]) {
+ vms.push(
+ await AgentOs.create({
+ sidecar: { kind: "explicit", handle: sidecar },
+ defaultSoftware: false,
+ permissions,
+ hostFunctions: {
+ identity: {
+ who: { inputSchema: z.object({}), execute: () => ({ id }) },
+ },
+ },
+ }),
+ );
+ }
+ await invoke(vms[0], "a");
+ await invoke(vms[1], "b");
+ await invoke(vms[2], "c");
+ await vms[1].dispose();
+ await invoke(vms[0], "a");
+ vms.push(
+ await AgentOs.create({
+ sidecar: { kind: "explicit", handle: sidecar },
+ defaultSoftware: false,
+ permissions,
+ }),
+ );
+ await invoke(vms[0], "a");
+ await invoke(vms[2], "c");
+ } finally {
+ try {
+ await Promise.all(vms.map((vm) => vm.dispose()));
+ } finally {
+ await sidecar.dispose();
+ }
+ }
+ });
+});
diff --git a/packages/core/tests/leak-agent-os-processes.test.ts b/packages/core/tests/leak-agent-os-processes.test.ts
index f777a92727..2c9a5a4e91 100644
--- a/packages/core/tests/leak-agent-os-processes.test.ts
+++ b/packages/core/tests/leak-agent-os-processes.test.ts
@@ -26,7 +26,10 @@ function makeAgentOs(): {
} {
const noop = () => {};
const kernelMock = { dispose: async () => {} };
- const sidecarClientMock = { onEvent: () => noop };
+ const sidecarClientMock = {
+ onEvent: () => noop,
+ setSidecarRequestHandler: noop,
+ };
// The constructor is private at the type level but callable at runtime; it
// runs the class field initializers (including `_processes = new Map()`).
diff --git a/packages/core/tests/native-sidecar-process.nightly.test.ts b/packages/core/tests/native-sidecar-process.nightly.test.ts
index 7a7bd896e7..d19cbc4e79 100644
--- a/packages/core/tests/native-sidecar-process.nightly.test.ts
+++ b/packages/core/tests/native-sidecar-process.nightly.test.ts
@@ -5,8 +5,8 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
- rmSync,
realpathSync,
+ rmSync,
statSync,
symlinkSync,
writeFileSync,
@@ -20,10 +20,10 @@ import { createHostDirBackend } from "../src/host-dir-mount.js";
import {
createKernel,
createNodeRuntime,
- NodeFileSystem,
createWasmVmRuntime,
+ NodeFileSystem,
} from "../src/runtime-compat.js";
-import { createInMemoryFileSystem } from "../src/test/runtime.js";
+import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js";
import {
NativeSidecarKernelProxy,
NativeSidecarProcessClient,
@@ -34,7 +34,7 @@ import {
serializeRootFilesystemForSidecar,
toSidecarSignalName,
} from "../src/sidecar/rpc-client.js";
-import { serializePermissionsForSidecar } from "../src/sidecar/permissions.js";
+import { createInMemoryFileSystem } from "../src/test/runtime.js";
import {
findPackageWithCommand,
packageCommandsDir,
@@ -44,9 +44,7 @@ const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
const SIDECAR_BINARY = process.env.AGENTOS_SIDECAR_BIN
? resolve(process.env.AGENTOS_SIDECAR_BIN)
: join(REPO_ROOT, "target/debug/agentos-native-sidecar");
-const REGISTRY_COMMANDS_DIR = packageCommandsDir(
- findPackageWithCommand("sh"),
-);
+const REGISTRY_COMMANDS_DIR = packageCommandsDir(findPackageWithCommand("sh"));
const SIGNAL_STATE_CONTROL_PREFIX = "__AGENT_OS_SIGNAL_STATE__:";
const ALLOW_ALL_VM_PERMISSIONS = {
fs: "allow",
@@ -389,7 +387,9 @@ describe("native sidecar process client", () => {
});
test("dispatches BARE sidecar_request frames to the registered handler", async () => {
- const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-request-"));
+ const fixtureRoot = mkdtempSync(
+ join(tmpdir(), "agentos-native-sidecar-request-"),
+ );
cleanupPaths.push(fixtureRoot);
const capturePath = join(fixtureRoot, "captured-response.json");
const driverPath = join(fixtureRoot, "fake-sidecar.mjs");
@@ -459,7 +459,7 @@ describe("native sidecar process client", () => {
content: "from-handler",
},
};
- });
+ }, "vm-1");
try {
const captured = await waitFor(
@@ -496,7 +496,9 @@ describe("native sidecar process client", () => {
});
test("dispose forcibly terminates a sidecar that ignores stdin closure", async () => {
- const fixtureRoot = mkdtempSync(join(tmpdir(), "agentos-native-sidecar-dispose-"));
+ const fixtureRoot = mkdtempSync(
+ join(tmpdir(), "agentos-native-sidecar-dispose-"),
+ );
cleanupPaths.push(fixtureRoot);
const driverPath = join(fixtureRoot, "stuck-sidecar.mjs");
writeFileSync(
diff --git a/packages/runtime-core/src/native-client.ts b/packages/runtime-core/src/native-client.ts
index a961fd1c22..4fec389c52 100644
--- a/packages/runtime-core/src/native-client.ts
+++ b/packages/runtime-core/src/native-client.ts
@@ -22,6 +22,7 @@ export interface StdioSidecarProtocolClientSpawnOptions {
command?: string;
args?: string[];
eventBufferCapacity?: number;
+ maxVmRequestHandlers?: number;
gracefulExitMs?: number;
forceExitMs?: number;
disposedErrorMessage?: string;
@@ -44,7 +45,10 @@ type ResolvedStdioSidecarProtocolClientOptions = Required<
| "payloadCodec"
>
> &
- Pick;
+ Pick<
+ StdioSidecarProtocolClientSpawnOptions,
+ "silenceTimeoutMs" | "maxVmRequestHandlers"
+ >;
export class StdioSidecarProtocolClient implements SidecarProcessTransport {
readonly child: StdioSidecarProcess["child"];
@@ -77,6 +81,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
this.protocolClient = new SidecarProtocolClient({
...transportOptions,
eventBufferCapacity: options.eventBufferCapacity,
+ maxVmRequestHandlers: options.maxVmRequestHandlers,
payloadCodec: options.payloadCodec,
silenceTimeoutMs: options.silenceTimeoutMs,
// A silent sidecar is dead or wedged; reap the process so it cannot
@@ -125,6 +130,7 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
}),
{
silenceTimeoutMs: options.silenceTimeoutMs,
+ maxVmRequestHandlers: options.maxVmRequestHandlers,
eventBufferCapacity:
options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY,
gracefulExitMs:
@@ -137,8 +143,11 @@ export class StdioSidecarProtocolClient implements SidecarProcessTransport {
);
}
- setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void {
- this.protocolClient.setSidecarRequestHandler(handler);
+ setSidecarRequestHandler(
+ handler: LiveSidecarRequestHandler | null,
+ vmId?: string,
+ ): void {
+ this.protocolClient.setSidecarRequestHandler(handler, vmId);
}
onEvent(handler: (event: LiveEventFrame) => void): () => void {
diff --git a/packages/runtime-core/src/protocol-client.ts b/packages/runtime-core/src/protocol-client.ts
index a279b477d3..7a92f346c6 100644
--- a/packages/runtime-core/src/protocol-client.ts
+++ b/packages/runtime-core/src/protocol-client.ts
@@ -33,6 +33,7 @@ import type { LiveRequestPayload } from "./request-payloads.js";
import {
SidecarRejectedError,
SidecarSilenceTimeout,
+ SidecarVmRequestHandlerLimit,
} from "./sidecar-errors.js";
/**
@@ -61,6 +62,8 @@ export interface SidecarProtocolClientOptions {
/** Use one bidirectional stdin/stdout transport when the host cannot inherit fd 3. */
combinedStdio?: boolean;
eventBufferCapacity: number;
+ /** Maximum VM callback dispatchers on this connection (default 4096). */
+ maxVmRequestHandlers?: number;
payloadCodec?: ProtocolFramePayloadCodec;
stderrText?: () => string;
frameError?: (error: Error) => Error;
@@ -97,9 +100,24 @@ export class SidecarProtocolClient {
reject: (error: Error) => void;
timer: ReturnType | null;
}>();
+ private readonly maxVmRequestHandlers: number;
+ private vmHandlerWarningActive = false;
private sidecarRequestHandler: LiveSidecarRequestHandler | null = null;
+ private readonly vmRequestHandlers = new Map<
+ string,
+ LiveSidecarRequestHandler
+ >();
constructor(options: SidecarProtocolClientOptions) {
+ this.maxVmRequestHandlers = options.maxVmRequestHandlers ?? 4096;
+ if (
+ !Number.isSafeInteger(this.maxVmRequestHandlers) ||
+ this.maxVmRequestHandlers <= 0
+ ) {
+ throw new RangeError(
+ "maxVmRequestHandlers must be a positive safe integer",
+ );
+ }
this.silenceTimeoutMs =
options.silenceTimeoutMs ?? DEFAULT_SIDECAR_SILENCE_TIMEOUT_MS;
this.eventBuffer = new SidecarEventBuffer(options.eventBufferCapacity);
@@ -179,8 +197,35 @@ export class SidecarProtocolClient {
}
}
- setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void {
- this.sidecarRequestHandler = handler;
+ setSidecarRequestHandler(
+ handler: LiveSidecarRequestHandler | null,
+ vmId?: string,
+ ): void {
+ if (handler && this.closedError) throw this.closedError;
+ if (vmId !== undefined) {
+ if (handler) {
+ if (
+ !this.vmRequestHandlers.has(vmId) &&
+ this.vmRequestHandlers.size >= this.maxVmRequestHandlers
+ ) {
+ throw new SidecarVmRequestHandlerLimit(this.maxVmRequestHandlers);
+ }
+ this.vmRequestHandlers.set(vmId, handler);
+ } else {
+ this.vmRequestHandlers.delete(vmId);
+ }
+ const nearCapacity =
+ this.vmRequestHandlers.size >=
+ Math.ceil(this.maxVmRequestHandlers * 0.8);
+ if (nearCapacity && !this.vmHandlerWarningActive) {
+ console.warn(
+ `agentOS VM callback handlers near maxVmRequestHandlers (${this.vmRequestHandlers.size}/${this.maxVmRequestHandlers}); raise SidecarSpawnOptions.maxVmRequestHandlers`,
+ );
+ }
+ this.vmHandlerWarningActive = nearCapacity;
+ } else {
+ this.sidecarRequestHandler = handler;
+ }
}
onEvent(handler: (event: LiveEventFrame) => void): () => void {
@@ -305,12 +350,14 @@ export class SidecarProtocolClient {
}
}
this.closedError = error;
+ this.vmRequestHandlers.clear();
+ this.sidecarRequestHandler = null;
this.stopSilenceWatchdog();
this.rejectPending(error);
}
dispose(): void {
- this.stopSilenceWatchdog();
+ this.failPermanently(new Error("sidecar protocol client disposed"));
this.frameTransport.dispose();
}
@@ -340,7 +387,9 @@ export class SidecarProtocolClient {
});
const payload = await resolveSidecarRequestFramePayload(
request,
- this.sidecarRequestHandler,
+ request.ownership.scope === "vm"
+ ? (this.vmRequestHandlers.get(request.ownership.vm_id) ?? null)
+ : this.sidecarRequestHandler,
);
try {
diff --git a/packages/runtime-core/src/sidecar-client.ts b/packages/runtime-core/src/sidecar-client.ts
index 75423b704e..6f179aab83 100644
--- a/packages/runtime-core/src/sidecar-client.ts
+++ b/packages/runtime-core/src/sidecar-client.ts
@@ -8,16 +8,17 @@ import type {
import type { LiveRequestPayload } from "./request-payloads.js";
export interface SidecarProcessTransport {
- setSidecarRequestHandler(handler: LiveSidecarRequestHandler | null): void;
+ setSidecarRequestHandler(
+ handler: LiveSidecarRequestHandler | null,
+ vmId?: string,
+ ): void;
onEvent(handler: (event: LiveEventFrame) => void): () => void;
sendRequest(input: {
ownership: LiveOwnershipScope;
payload: LiveRequestPayload;
}): Promise;
waitForEvent(
- matcher:
- | LiveSidecarEventSelector
- | ((event: LiveEventFrame) => boolean),
+ matcher: LiveSidecarEventSelector | ((event: LiveEventFrame) => boolean),
timeoutMs?: number,
options?: {
signal?: AbortSignal;
diff --git a/packages/runtime-core/src/sidecar-errors.ts b/packages/runtime-core/src/sidecar-errors.ts
index c3c7e5d140..a716ead411 100644
--- a/packages/runtime-core/src/sidecar-errors.ts
+++ b/packages/runtime-core/src/sidecar-errors.ts
@@ -94,3 +94,14 @@ export class SidecarProcessError extends Error {
this.stderr = stderr;
}
}
+
+export class SidecarVmRequestHandlerLimit extends Error {
+ readonly limit: number;
+ constructor(limit: number) {
+ super(
+ `VM callback handler limit maxVmRequestHandlers (${limit}) reached; raise SidecarSpawnOptions.maxVmRequestHandlers`,
+ );
+ this.name = "SidecarVmRequestHandlerLimit";
+ this.limit = limit;
+ }
+}
diff --git a/packages/runtime-core/src/sidecar-process.ts b/packages/runtime-core/src/sidecar-process.ts
index bc4a09ef5b..1833058c54 100644
--- a/packages/runtime-core/src/sidecar-process.ts
+++ b/packages/runtime-core/src/sidecar-process.ts
@@ -203,6 +203,8 @@ export interface SidecarSpawnOptions {
command?: string;
args?: string[];
eventBufferCapacity?: number;
+ /** Maximum VM callback dispatchers on this connection (default 4096). */
+ maxVmRequestHandlers?: number;
gracefulExitMs?: number;
forceExitMs?: number;
// Migration-only compatibility path for pre-BARE test fixtures.
@@ -220,6 +222,7 @@ export interface ResolvedSidecarSpawnOptions {
command?: string;
args: string[];
eventBufferCapacity: number;
+ maxVmRequestHandlers?: number;
gracefulExitMs: number;
forceExitMs: number;
disposedErrorMessage: string;
@@ -367,6 +370,7 @@ export class SidecarProcess {
args: options.args ?? [],
cwd: options.cwd,
silenceTimeoutMs: options.silenceTimeoutMs,
+ maxVmRequestHandlers: options.maxVmRequestHandlers,
eventBufferCapacity:
options.eventBufferCapacity ?? DEFAULT_SIDECAR_EVENT_BUFFER_CAPACITY,
gracefulExitMs:
@@ -378,8 +382,11 @@ export class SidecarProcess {
return SidecarProcess.fromClient(protocolClient);
}
- setSidecarRequestHandler(handler: SidecarRequestHandler | null): void {
- this.protocolClient.setSidecarRequestHandler(handler);
+ setSidecarRequestHandler(
+ handler: SidecarRequestHandler | null,
+ vmId?: string,
+ ): void {
+ this.protocolClient.setSidecarRequestHandler(handler, vmId);
}
onEvent(handler: (event: EventFrame) => void): () => void {
diff --git a/packages/runtime-core/src/test-runtime.ts b/packages/runtime-core/src/test-runtime.ts
index 24fbdd8812..b9d335d212 100644
--- a/packages/runtime-core/src/test-runtime.ts
+++ b/packages/runtime-core/src/test-runtime.ts
@@ -2957,6 +2957,11 @@ class NativeKernel implements Kernel {
try {
await this.proxy?.dispose().catch(() => {});
} finally {
+ if (this.hostFunctionRequestHandlerInstalled && this.client && this.vm) {
+ this.client.setSidecarRequestHandler(null, this.vm.vmId);
+ }
+ this.hostFunctionRequestHandlerInstalled = false;
+ this.hostFunctionHandlers.clear();
this.proxy = null;
this.rootFilesystem = null;
this.client = null;
@@ -3096,8 +3101,10 @@ class NativeKernel implements Kernel {
// sidecar emits to the matching registered handler and replies with a
// host_callback_result frame.
if (!this.hostFunctionRequestHandlerInstalled) {
- this.client.setSidecarRequestHandler((request: SidecarRequestFrame) =>
- this.dispatchHostFunctionRequest(request),
+ this.client.setSidecarRequestHandler(
+ (request: SidecarRequestFrame) =>
+ this.dispatchHostFunctionRequest(request),
+ this.vm.vmId,
);
this.hostFunctionRequestHandlerInstalled = true;
}
diff --git a/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts b/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts
index a2a8ed8d9e..cbb43a52e4 100644
--- a/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts
+++ b/packages/runtime-core/tests/mount-fs-custom-vfs.test.ts
@@ -1,4 +1,5 @@
-import { afterEach, describe, expect, test } from "vitest";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import type { SidecarProcess } from "../src/sidecar-process.js";
import {
createInMemoryFileSystem,
createKernel,
@@ -63,26 +64,22 @@ describe("Kernel.mountFs custom JS VFS", () => {
kernel = undefined;
});
- test(
- "routes runtime reads and writes through a plain JS VFS object",
- async () => {
- const mounted = createRecordingFilesystem();
- kernel = createKernel({ filesystem: createInMemoryFileSystem() });
+ test("routes runtime reads and writes through a plain JS VFS object", async () => {
+ const mounted = createRecordingFilesystem();
+ kernel = createKernel({ filesystem: createInMemoryFileSystem() });
- kernel.mountFs("/mnt/custom", mounted.fs);
- await kernel.writeFile("/mnt/custom/note.txt", "from custom vfs");
+ kernel.mountFs("/mnt/custom", mounted.fs);
+ await kernel.writeFile("/mnt/custom/note.txt", "from custom vfs");
- expect(
- new TextDecoder().decode(await kernel.readFile("/mnt/custom/note.txt")),
- ).toBe("from custom vfs");
- expect(mounted.calls).toContain("writeFile:/note.txt");
- expect(mounted.calls).toContain("readFile:/note.txt");
+ expect(
+ new TextDecoder().decode(await kernel.readFile("/mnt/custom/note.txt")),
+ ).toBe("from custom vfs");
+ expect(mounted.calls).toContain("writeFile:/note.txt");
+ expect(mounted.calls).toContain("readFile:/note.txt");
- kernel.unmountFs("/mnt/custom");
- await expect(kernel.readFile("/mnt/custom/note.txt")).rejects.toThrow();
- },
- 120_000,
- );
+ kernel.unmountFs("/mnt/custom");
+ await expect(kernel.readFile("/mnt/custom/note.txt")).rejects.toThrow();
+ }, 120_000);
test("routes positioned writes through a mounted JS VFS", async () => {
const mounted = createRecordingFilesystem();
@@ -102,3 +99,47 @@ describe("Kernel.mountFs custom JS VFS", () => {
expect(mounted.calls).toContain("pwrite:/db.bin");
});
});
+
+test("disposal unregisters only the shared kernel VM callback", async () => {
+ const sidecar = {
+ authenticateAndOpenSession: vi.fn(async () => ({
+ connectionId: "conn",
+ sessionId: "session",
+ })),
+ createVm: vi
+ .fn()
+ .mockResolvedValueOnce({ vmId: "a" })
+ .mockResolvedValueOnce({ vmId: "b" }),
+ waitForEvent: vi.fn(async () => ({})),
+ onEvent: vi.fn(() => () => {}),
+ setSidecarRequestHandler: vi.fn(),
+ disposeVm: vi.fn(async () => {}),
+ dispose: vi.fn(async () => {}),
+ } as unknown as SidecarProcess;
+ const a = createKernel({
+ filesystem: createInMemoryFileSystem(),
+ sidecar,
+ syncFilesystemOnDispose: false,
+ });
+ const b = createKernel({
+ filesystem: createInMemoryFileSystem(),
+ sidecar,
+ syncFilesystemOnDispose: false,
+ });
+ try {
+ await a.registerHostFunctions({});
+ await b.registerHostFunctions({});
+ await a.dispose();
+ expect(sidecar.setSidecarRequestHandler).toHaveBeenCalledWith(null, "a");
+ expect(sidecar.setSidecarRequestHandler).not.toHaveBeenCalledWith(
+ null,
+ "b",
+ );
+ expect(sidecar.dispose).not.toHaveBeenCalled();
+ await b.dispose();
+ expect(sidecar.setSidecarRequestHandler).toHaveBeenCalledWith(null, "b");
+ } finally {
+ await a.dispose();
+ await b.dispose();
+ }
+});
diff --git a/packages/runtime-core/tests/protocol-client.test.ts b/packages/runtime-core/tests/protocol-client.test.ts
index dd4b7329ea..a8d905c55a 100644
--- a/packages/runtime-core/tests/protocol-client.test.ts
+++ b/packages/runtime-core/tests/protocol-client.test.ts
@@ -1,5 +1,5 @@
import { Duplex, PassThrough } from "node:stream";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import type { FrameTransport } from "../src/frame-stream.js";
import { encodeLengthPrefixedPayload } from "../src/framing.js";
import { SidecarProtocolClient } from "../src/protocol-client.js";
@@ -11,7 +11,10 @@ import {
type LiveSidecarRequestFrame,
} from "../src/protocol-frames.js";
import { SIDECAR_PROTOCOL_SCHEMA } from "../src/protocol-schema.js";
-import { SidecarRejectedError } from "../src/sidecar-errors.js";
+import {
+ SidecarRejectedError,
+ SidecarVmRequestHandlerLimit,
+} from "../src/sidecar-errors.js";
const ownership = {
scope: "connection" as const,
@@ -141,6 +144,57 @@ function writeIncomingControlFrame(
}
describe("sidecar protocol client", () => {
+ it("bounds VM callback registrations, warns near capacity, and reuses released slots", () => {
+ const client = new SidecarProtocolClient({
+ frameTransport: new MemoryFrameTransport(),
+ eventBufferCapacity: 2,
+ maxVmRequestHandlers: 5,
+ });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const handler = async () => ({
+ type: "host_callback_result" as const,
+ invocation_id: "invocation",
+ result: null,
+ });
+ try {
+ for (let i = 0; i < 4; i++)
+ client.setSidecarRequestHandler(handler, `vm-${i}`);
+ expect(warn).toHaveBeenCalledOnce();
+ expect(warn.mock.calls[0][0]).toContain("maxVmRequestHandlers");
+ client.setSidecarRequestHandler(handler, "vm-4");
+ expect(() => client.setSidecarRequestHandler(handler, "vm-5")).toThrow(
+ SidecarVmRequestHandlerLimit,
+ );
+ // Replacing an existing VM does not consume another slot.
+ client.setSidecarRequestHandler(handler, "vm-0");
+ client.setSidecarRequestHandler(null, "vm-1");
+ client.setSidecarRequestHandler(handler, "vm-5");
+ } finally {
+ warn.mockRestore();
+ client.dispose();
+ }
+ });
+
+ it("rejects new callback registrations after normal protocol disposal", () => {
+ const client = new SidecarProtocolClient({
+ frameTransport: new MemoryFrameTransport(),
+ eventBufferCapacity: 2,
+ });
+ client.dispose();
+ expect(() =>
+ client.setSidecarRequestHandler(
+ async () => ({
+ type: "host_callback_result",
+ invocation_id: "invocation",
+ result: null,
+ }),
+ "vm",
+ ),
+ ).toThrow("sidecar protocol client disposed");
+ // Cleanup remains possible after transport closure.
+ client.setSidecarRequestHandler(null, "vm");
+ });
+
it("preserves structured resource-limit rejection metadata", async () => {
const frameTransport = new MemoryFrameTransport();
const client = new SidecarProtocolClient({
@@ -440,6 +494,163 @@ describe("sidecar protocol client", () => {
client.dispose();
});
+ it.each([
+ "host_callback",
+ "js_bridge_call",
+ ] as const)("routes %s by VM without borrowing a sibling or global handler", async (type) => {
+ const bridge = type === "js_bridge_call";
+ const frameTransport = new MemoryFrameTransport();
+ const client = new SidecarProtocolClient({
+ frameTransport,
+ eventBufferCapacity: 8,
+ payloadCodec: "json",
+ });
+ const calls: string[] = [];
+ for (const vmId of ["a", "b"]) {
+ client.setSidecarRequestHandler(async (request) => {
+ calls.push(vmId);
+ if (request.payload.type === "js_bridge_call") {
+ return {
+ type: "js_bridge_result",
+ call_id: request.payload.call_id,
+ result: vmId,
+ };
+ }
+ return {
+ type: "host_callback_result",
+ invocation_id: "invoke",
+ result: vmId,
+ };
+ }, vmId);
+ }
+ client.setSidecarRequestHandler(async () => {
+ calls.push("global");
+ return {
+ type: "host_callback_result",
+ invocation_id: "invoke",
+ result: "global",
+ };
+ });
+ const emit = (vmId: string, requestId: number, bridge = false) =>
+ frameTransport.emitFrame({
+ frame_type: "sidecar_request",
+ schema: SIDECAR_PROTOCOL_SCHEMA,
+ request_id: requestId,
+ ownership: {
+ scope: "vm",
+ connection_id: "conn",
+ session_id: "session",
+ vm_id: vmId,
+ },
+ payload: bridge
+ ? {
+ type: "js_bridge_call",
+ call_id: "bridge",
+ mount_id: "mount",
+ operation: "read_file",
+ args: [],
+ }
+ : {
+ type: "host_callback",
+ invocation_id: "invoke",
+ callback_key: "tool",
+ input: {},
+ timeout_ms: 1000,
+ },
+ });
+ const response = (id: number) =>
+ frameTransport.writes.find(
+ (frame) => "request_id" in frame && frame.request_id === id,
+ );
+ try {
+ emit("a", -1, bridge);
+ emit("b", -2, bridge);
+ emit("unregistered", -3, bridge);
+ await expect.poll(() => frameTransport.writes.length).toBe(3);
+ expect(calls).toEqual(["a", "b"]);
+ expect(response(-1)).toMatchObject({
+ request_id: -1,
+ ownership: { scope: "vm", vm_id: "a" },
+ payload: { result: "a" },
+ });
+ expect(response(-2)).toMatchObject({
+ request_id: -2,
+ payload: { result: "b" },
+ });
+ expect(response(-3)).toMatchObject({
+ request_id: -3,
+ payload: {
+ error: expect.stringContaining("no sidecar request handler"),
+ },
+ });
+ client.setSidecarRequestHandler(null, "b");
+ client.setSidecarRequestHandler(null, "disabled");
+ emit("b", -4, bridge);
+ emit("a", -5, bridge);
+ await expect.poll(() => frameTransport.writes.length).toBe(5);
+ expect(calls).toEqual(["a", "b", "a"]);
+ expect(response(-4)).toMatchObject({
+ request_id: -4,
+ payload: { error: expect.any(String) },
+ });
+ expect(response(-5)).toMatchObject({
+ request_id: -5,
+ payload: { result: "a" },
+ });
+ } finally {
+ client.dispose();
+ }
+ });
+
+ it.each([
+ "connection",
+ "session",
+ ] as const)("keeps the global handler for %s requests", async (scope) => {
+ const frameTransport = new MemoryFrameTransport();
+ const client = new SidecarProtocolClient({
+ frameTransport,
+ eventBufferCapacity: 8,
+ payloadCodec: "json",
+ });
+ client.setSidecarRequestHandler(async () => ({
+ type: "host_callback_result",
+ invocation_id: "invoke",
+ result: "global",
+ }));
+ client.setSidecarRequestHandler(
+ async () => ({
+ type: "host_callback_result",
+ invocation_id: "invoke",
+ result: "vm",
+ }),
+ "a",
+ );
+ try {
+ frameTransport.emitFrame({
+ frame_type: "sidecar_request",
+ schema: SIDECAR_PROTOCOL_SCHEMA,
+ request_id: -1,
+ ownership:
+ scope === "connection"
+ ? ownership
+ : { scope, connection_id: "conn", session_id: "session" },
+ payload: {
+ type: "host_callback",
+ invocation_id: "invoke",
+ callback_key: "tool",
+ input: {},
+ timeout_ms: 1000,
+ },
+ });
+ await expect.poll(() => frameTransport.writes.length).toBe(1);
+ expect(frameTransport.writes[0]).toMatchObject({
+ payload: { result: "global" },
+ });
+ } finally {
+ client.dispose();
+ }
+ });
+
it("writes typed shutdown control on fd3 without touching fd0", async () => {
const { stdin, control, client } = createClient();
const written = readWrittenFrame(control.written);