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
16 changes: 13 additions & 3 deletions crates/native-sidecar/src/language_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const INLINE_FILE_PATH_ENV: &str = "AGENTOS_INLINE_FILE_PATH";
const USE_BUNDLED_TYPESCRIPT_ENV: &str = "AGENTOS_USE_BUNDLED_TYPESCRIPT";
const SEMANTIC_RESULT_PATH_PREFIX: &str = "/tmp/.agentos-semantic-result-";

/// Monotonic suffix for minted public execution ids. Process-wide so ids stay
/// unique across every VM this sidecar hosts, not merely within one VM.
static NEXT_PUBLIC_EXECUTION_ID: AtomicU64 = AtomicU64::new(1);

#[derive(Debug)]
struct LoweredOperation {
identity: ExecutionIdentityOptions,
Expand Down Expand Up @@ -1076,9 +1080,15 @@ where
execution_id
}
None => loop {
vm.next_public_execution_id = vm.next_public_execution_id.saturating_add(1);
let candidate =
format!("operation-{now:x}-{:x}", vm.next_public_execution_id);
// Process-wide, not per-VM. One sidecar process fans out to
// many VMs whose events reach the host over a single shared
// client that routes execution events by id alone, so a
// per-VM counter makes K VMs mint the identical
// `operation-{ms}-1` in the same millisecond and cross-talk.
let candidate = format!(
"operation-{now:x}-{:x}",
NEXT_PUBLIC_EXECUTION_ID.fetch_add(1, Ordering::Relaxed)
);
if !vm.executions.contains_key(&candidate) {
break candidate;
}
Expand Down
1 change: 0 additions & 1 deletion crates/native-sidecar/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,6 @@ pub(crate) struct VmState {
/// Process IDs remain internal routing details in `execution_processes`.
pub(crate) executions: BTreeMap<String, ManagedLanguageExecution>,
pub(crate) execution_processes: BTreeMap<String, String>,
pub(crate) next_public_execution_id: u64,
pub(crate) execution_retention_wake_deadline_ms: Option<u64>,
pub(crate) execution_retention_wake_task: Option<tokio::task::JoinHandle<()>>,
/// The VM filesystem is shared across executions, so package-manager
Expand Down
1 change: 0 additions & 1 deletion crates/native-sidecar/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,6 @@ where
next_vm_fetch_stream_id: 0,
executions: BTreeMap::new(),
execution_processes: BTreeMap::new(),
next_public_execution_id: 0,
execution_retention_wake_deadline_ms: None,
execution_retention_wake_task: None,
package_mutation_execution_id: None,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6007,6 +6007,19 @@ export class AgentOs {
? T
: never,
): void {
// Every AgentOs sharing a sidecar handle listens on the SAME native
// process, so this callback sees every tenant VM's events. Execution ids
// are only unique within a VM, so dispatching on the id alone leaks
// another VM's stdout/exit into this instance. Drop foreign vm-scoped
// events here — before the mappers, which discard `ownership` — so every
// downstream id-keyed lookup below is automatically VM-local. Session-
// and connection-scoped events are not VM-specific and pass through.
if (
event.ownership.scope === "vm" &&
event.ownership.vm_id !== this._sidecarVm.vmId
) {
return;
}
if (event.payload.type === "execution_output") {
const output = mapExecutionOutputEvent(event.payload.event);
const pid = this._languageProcessIds.get(output.executionId);
Expand Down
115 changes: 115 additions & 0 deletions packages/core/tests/cross-vm-execution-event-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "vitest";
import { AgentOs } from "../src/index.js";

// odw-0a6. Every AgentOs created from one sidecar handle listens on the SAME
// native process, and execution ids are only unique inside a VM (they used to
// be minted from a per-VM counter, so K VMs fanning out minted the identical id
// in the same millisecond). Dispatching on the id alone therefore delivered
// VM B's stdout and exit into VM A. `_handleSidecarEvent` must drop foreign
// vm-scoped frames BEFORE the mappers run, since the mappers discard
// `ownership` entirely.
//
// Built on the prototype rather than `AgentOs.create()`: the guard is pure
// routing over the instance's own maps, and a real VM would drag in the whole
// guest-software toolchain for nothing.
const OWN_VM_ID = "vm-A";
const FOREIGN_VM_ID = "vm-B";
const COLLIDING_EXECUTION_ID = "operation-1-1";

function ownership(vmId: string) {
return {
scope: "vm" as const,
connection_id: "conn-1",
session_id: "session-1",
vm_id: vmId,
};
}

function outputFrame(vmId: string) {
return {
ownership: ownership(vmId),
payload: {
type: "execution_output" as const,
event: {
executionId: COLLIDING_EXECUTION_ID,
generation: 1n,
processId: null,
sequence: 0n,
channel: "Stdout",
chunk: new TextEncoder().encode("secret").buffer,
timestampMs: 0n,
},
},
};
}

function completedFrame(vmId: string) {
return {
ownership: ownership(vmId),
payload: {
type: "execution_completed" as const,
event: {
executionId: COLLIDING_EXECUTION_ID,
generation: 1n,
outcome: "Succeeded",
exitCode: 0,
error: null,
},
},
};
}

interface EventRouterProbe {
_sidecarVm: { vmId: string };
_languageProcessIds: Map<string, number>;
_languageProcesses: Map<number, unknown>;
_executionOutputHandlers: Map<string, Set<(event: unknown) => void>>;
_executionCompletedHandlers: Map<string, Set<(event: unknown) => void>>;
_handleSidecarEvent(event: unknown): void;
}

function eventRouter(): {
probe: EventRouterProbe;
output: unknown[];
completed: unknown[];
} {
const probe = Object.create(AgentOs.prototype) as EventRouterProbe;
probe._sidecarVm = { vmId: OWN_VM_ID };
probe._languageProcessIds = new Map();
probe._languageProcesses = new Map();
const output: unknown[] = [];
const completed: unknown[] = [];
probe._executionOutputHandlers = new Map([
["*", new Set([(event: unknown) => void output.push(event)])],
]);
probe._executionCompletedHandlers = new Map([
["*", new Set([(event: unknown) => void completed.push(event)])],
]);
return { probe, output, completed };
}

describe("cross-VM execution event isolation", () => {
test("an execution event owned by another VM is never dispatched here", () => {
const { probe, output, completed } = eventRouter();

probe._handleSidecarEvent(outputFrame(FOREIGN_VM_ID));
probe._handleSidecarEvent(completedFrame(FOREIGN_VM_ID));

expect(output).toEqual([]);
expect(completed).toEqual([]);
});

test("the same execution id owned by this VM is still dispatched", () => {
const { probe, output, completed } = eventRouter();

probe._handleSidecarEvent(outputFrame(OWN_VM_ID));
probe._handleSidecarEvent(completedFrame(OWN_VM_ID));

expect(output).toMatchObject([
{ executionId: COLLIDING_EXECUTION_ID, channel: "stdout" },
]);
expect(completed).toMatchObject([
{ executionId: COLLIDING_EXECUTION_ID, outcome: "succeeded" },
]);
});
});