From 3c5eff73f9a09a7a5d34d16cef30da6eebc1cdc0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 19 Oct 2026 23:53:17 -0700 Subject: [PATCH] feat(sidecar): expose host-function globals in every guest Node process --- crates/execution/src/javascript.rs | 12 +- .../src/execution/child_process.rs | 6 +- crates/native-sidecar/src/execution/launch.rs | 19 +- crates/native-sidecar/src/execution/mod.rs | 2 +- crates/native-sidecar/src/host_functions.rs | 110 +++++++++ .../native-sidecar/src/language_execution.rs | 132 ----------- docs/content/docs/host-functions.mdx | 10 +- docs/content/docs/javascript.mdx | 4 +- .../embedded/host-functions-javascript.ts | 12 + packages/core/package.json | 2 +- .../host-function-globals.nightly.test.ts | 68 ++++++ .../core/tests/host-function-globals.test.ts | 216 ++++++++++++++++++ .../docs/content/docs/host-functions.mdx | 2 + 13 files changed, 452 insertions(+), 143 deletions(-) create mode 100644 packages/core/tests/host-function-globals.nightly.test.ts create mode 100644 packages/core/tests/host-function-globals.test.ts diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index d16d075b93..8303f62037 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -515,6 +515,12 @@ pub struct GuestRuntimeConfig { /// keeps the bridge-only snapshot (unchanged behavior). The runtime caches the /// snapshot process-wide keyed by sha256(bridge_code + this bundle). pub snapshot_userland_code: Option, + /// Optional sidecar-owned classic script evaluated in the execution's + /// context before the entrypoint, including before an ES module entrypoint + /// links its static imports. The sidecar uses it to define VM-wide guest + /// globals such as host-function collections. Runner isolates for WASM and + /// Python leave it unset. + pub prelude_script: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2954,7 +2960,11 @@ impl JavascriptExecutionEngine { mode: u8::from(use_module_mode) | if retain_context { 2 } else { 0 }, file_path: execution_file_path, bridge_code: V8RuntimeHost::bridge_code().to_owned(), - post_restore_script: String::new(), + post_restore_script: request + .guest_runtime + .prelude_script + .clone() + .unwrap_or_default(), userland_code: snapshot_userland_code, high_resolution_time: request.guest_runtime.high_resolution_time, user_code, diff --git a/crates/native-sidecar/src/execution/child_process.rs b/crates/native-sidecar/src/execution/child_process.rs index b69a626ba0..60e7e8b60e 100644 --- a/crates/native-sidecar/src/execution/child_process.rs +++ b/crates/native-sidecar/src/execution/child_process.rs @@ -4893,7 +4893,7 @@ where let execution_result = javascript_engine .start_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { - guest_runtime: guest_runtime_identity( + guest_runtime: javascript_guest_runtime( &vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), @@ -5575,7 +5575,7 @@ where let replacement_result = javascript_engine .prepare_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { - guest_runtime: guest_runtime_identity( + guest_runtime: javascript_guest_runtime( vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), @@ -6515,7 +6515,7 @@ where let execution_result = javascript_engine .start_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { - guest_runtime: guest_runtime_identity( + guest_runtime: javascript_guest_runtime( &vm, Some(u64::from(kernel_pid)), Some(u64::from(parent_kernel_pid)), diff --git a/crates/native-sidecar/src/execution/launch.rs b/crates/native-sidecar/src/execution/launch.rs index cf087ba904..109ffc9085 100644 --- a/crates/native-sidecar/src/execution/launch.rs +++ b/crates/native-sidecar/src/execution/launch.rs @@ -3023,9 +3023,26 @@ pub(super) fn guest_runtime_identity( .as_ref() .is_some_and(|cfg| cfg.high_resolution_time.unwrap_or(false)), snapshot_userland_code: None, + prelude_script: None, } } +/// Guest-runtime config for a guest Node process. Adds the VM's host-function +/// globals to the shared identity so every guest Node process, however it was +/// launched, can call registered host functions directly. +pub(super) fn javascript_guest_runtime( + vm: &VmState, + virtual_pid: Option, + virtual_ppid: Option, +) -> GuestRuntimeConfig { + let mut config = guest_runtime_identity(vm, virtual_pid, virtual_ppid); + let script = host_function_globals_script(&vm.host_functions); + if !script.is_empty() { + config.prelude_script = Some(script); + } + config +} + /// The guest's virtual home directory, sourced from the VM user profile (the /// same value carried to the guest as `os.homedir()` via `guest_runtime`). Used /// by sidecar-internal `~`-path resolution; falls back to `/root` for a @@ -5076,7 +5093,7 @@ where let execution = javascript_engine .start_execution_with_module_reader_and_runtime( StartJavascriptExecutionRequest { - guest_runtime: guest_runtime_identity(&vm, None, None), + guest_runtime: javascript_guest_runtime(&vm, None, None), vm_id: vm_id.clone(), context_id: context.context_id, argv: std::iter::once(launch_entrypoint.clone()) diff --git a/crates/native-sidecar/src/execution/mod.rs b/crates/native-sidecar/src/execution/mod.rs index 2a2a0034dc..60495fb929 100644 --- a/crates/native-sidecar/src/execution/mod.rs +++ b/crates/native-sidecar/src/execution/mod.rs @@ -102,7 +102,7 @@ use crate::filesystem::{ service_owned_python_filesystem_rpc_request, }; use crate::host_functions::{ - format_host_function_failure_output, is_host_function_command, + format_host_function_failure_output, host_function_globals_script, is_host_function_command, normalized_host_function_command_name, resolve_host_function_command, HostFunctionCommandResolution, }; diff --git a/crates/native-sidecar/src/host_functions.rs b/crates/native-sidecar/src/host_functions.rs index dc26956a13..4b3641f2ba 100644 --- a/crates/native-sidecar/src/host_functions.rs +++ b/crates/native-sidecar/src/host_functions.rs @@ -715,6 +715,59 @@ fn host_function_registration_error(error: HostFunctionRegistrationError) -> Sid } } +/// Convert a kebab-case host function or collection name to the camelCase +/// identifier +/// guest JavaScript uses for it. +fn host_function_identifier(name: &str) -> String { + let mut identifier = String::with_capacity(name.len()); + let mut upper_next = false; + for character in name.chars() { + if character == '-' { + upper_next = true; + } else if upper_next { + identifier.extend(character.to_uppercase()); + upper_next = false; + } else { + identifier.push(character); + } + } + identifier +} + +/// Guest script that defines one frozen global per registered host function +/// collection, so guest JavaScript can `await orders.list({ ... })` instead of +/// spawning the `agentos-orders` command by hand. The sidecar runs it before +/// the entrypoint of every guest Node process in the VM. Each function runs +/// that same command, so the `hostFunction` permission scope, input +/// validation, and timeouts all still apply. A collection whose name is +/// already a global is skipped with a warning on stderr rather than shadowing +/// it. Returns an empty string when the VM has no host functions. +pub(crate) fn host_function_globals_script( + host_functions: &BTreeMap, +) -> String { + const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#; + + let groups = host_functions + .values() + .filter_map(|collection| { + let command = collection.command_aliases.first()?; + let functions = collection + .callbacks + .keys() + .map(|name| serde_json::json!([host_function_identifier(name), name])) + .collect::>(); + Some(( + host_function_identifier(&collection.name), + serde_json::json!({ "command": command, "functions": functions }), + )) + }) + .collect::>(); + if groups.is_empty() { + return String::new(); + } + format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups)) +} + enum HostFunctionCommand { Registry(String), Collection { collection_name: String }, @@ -1042,3 +1095,60 @@ mod tests { ); } } + +#[cfg(test)] +mod host_function_globals_tests { + use super::*; + use crate::protocol::RegisteredHostCallbackDefinition; + + fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest { + RegisterHostCallbacksRequest { + name: name.to_string(), + description: String::new(), + command_aliases: vec![format!("agentos-{name}")], + registry_command_aliases: vec![String::from("agentos")], + callbacks: callbacks + .iter() + .map(|callback| { + ( + callback.to_string(), + RegisteredHostCallbackDefinition { + description: String::new(), + input_schema: String::from("{}"), + timeout_ms: None, + examples: Vec::new(), + }, + ) + }) + .collect(), + } + } + + #[test] + fn converts_kebab_case_names_to_identifiers() { + assert_eq!(host_function_identifier("orders"), "orders"); + assert_eq!(host_function_identifier("order-store"), "orderStore"); + assert_eq!( + host_function_identifier("list-open-orders"), + "listOpenOrders" + ); + } + + #[test] + fn is_empty_without_host_functions() { + assert_eq!(host_function_globals_script(&BTreeMap::new()), ""); + } + + #[test] + fn defines_one_group_per_collection_on_a_single_line() { + let host_functions = BTreeMap::from([( + String::from("order-store"), + collection("order-store", &["list-orders"]), + )]); + let script = host_function_globals_script(&host_functions); + assert!(!script.contains('\n'), "script must stay on one line"); + assert!(script.ends_with( + r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"# + )); + } +} diff --git a/crates/native-sidecar/src/language_execution.rs b/crates/native-sidecar/src/language_execution.rs index 8a37a2fd6e..3e91972dca 100644 --- a/crates/native-sidecar/src/language_execution.rs +++ b/crates/native-sidecar/src/language_execution.rs @@ -115,59 +115,6 @@ fn inline_inputs_prefix(inputs: Option, python: bool) -> String { } } -/// Convert a kebab-case host function or collection name to the camelCase -/// identifier -/// guest JavaScript uses for it. -fn host_function_identifier(name: &str) -> String { - let mut identifier = String::with_capacity(name.len()); - let mut upper_next = false; - for character in name.chars() { - if character == '-' { - upper_next = true; - } else if upper_next { - identifier.extend(character.to_uppercase()); - upper_next = false; - } else { - identifier.push(character); - } - } - identifier -} - -/// Guest prefix that defines one frozen global per registered host function -/// collection, so inline JavaScript can `await orders.list({ ... })` instead of -/// spawning the `agentos-orders` command by hand. Each function runs that same -/// command, so the `hostFunction` permission scope, input validation, and -/// timeouts -/// all still apply. The prefix stays on one line so guest line numbers do not -/// shift. A collection whose name is already a global is skipped with a warning -/// on stderr rather than shadowing it. -fn inline_host_functions_prefix( - host_functions: &BTreeMap, -) -> String { - const DEFINE_GLOBALS: &str = r#"((groups)=>{const call=(command,name)=>(input={})=>import("node:child_process").then(({execFile})=>new Promise((resolve,reject)=>{execFile(command,[name,"--json",JSON.stringify(input)],{maxBuffer:67108864},(error,stdout,stderr)=>{if(error){reject(new Error(String(stderr||"").trim()||error.message));return;}let reply;try{reply=JSON.parse(String(stdout));}catch(parseError){reject(new Error("host function returned invalid JSON: "+parseError.message));return;}if(!reply||reply.ok!==true){reject(new Error((reply&&reply.error)||"host function failed"));return;}resolve(reply.result);});}));for(const [group,definition] of Object.entries(groups)){if(group in globalThis){console.error("agentos: host function group \""+group+"\" was not defined because globalThis."+group+" already exists");continue;}const functions={};for(const [key,name] of definition.functions)functions[key]=call(definition.command,name);Object.defineProperty(globalThis,group,{value:Object.freeze(functions),enumerable:true});}})"#; - - let groups = host_functions - .values() - .filter_map(|collection| { - let command = collection.command_aliases.first()?; - let functions = collection - .callbacks - .keys() - .map(|name| serde_json::json!([host_function_identifier(name), name])) - .collect::>(); - Some(( - host_function_identifier(&collection.name), - serde_json::json!({ "command": command, "functions": functions }), - )) - }) - .collect::>(); - if groups.is_empty() { - return String::new(); - } - format!("{DEFINE_GLOBALS}({});", serde_json::Value::Object(groups)) -} - fn semantic_result_path() -> String { static NEXT_RESULT_ID: AtomicU64 = AtomicU64::new(1); let nonce = NEXT_RESULT_ID.fetch_add(1, Ordering::Relaxed); @@ -1331,26 +1278,6 @@ where "retainEvents requires a context or spawned language process", )); } - // Inline JavaScript and TypeScript run as `node -e `. Give them the - // VM's registered host functions as globals. - if operation.retained_language == Some(RetainedExecutionLanguage::JavaScript) - && operation.args.first().map(String::as_str) == Some("-e") - { - let prefix = self - .vms - .get(&vm_id) - .map(|vm| inline_host_functions_prefix(&vm.host_functions)) - .unwrap_or_default(); - if !prefix.is_empty() { - if let Some(source) = operation.args.get_mut(1) { - source.insert_str(0, &prefix); - } - if let Some(source) = operation.retained_source.as_mut() { - source.insert_str(0, &prefix); - } - } - } - if !context_execution { // Ephemeral calls must not park a language interpreter after the // operation completes. @@ -3107,62 +3034,3 @@ fn execution_limit_error(limit: ExecutionLimit) -> ExecutionErrorData { details: None, } } - -#[cfg(test)] -mod inline_host_functions_prefix_tests { - use super::*; - - fn collection(name: &str, callbacks: &[&str]) -> RegisterHostCallbacksRequest { - RegisterHostCallbacksRequest { - name: name.to_string(), - description: String::new(), - command_aliases: vec![format!("agentos-{name}")], - registry_command_aliases: vec![String::from("agentos")], - callbacks: callbacks - .iter() - .map(|callback| { - ( - callback.to_string(), - RegisteredHostCallbackDefinition { - description: String::new(), - input_schema: String::from("{}"), - timeout_ms: None, - examples: Vec::new(), - }, - ) - }) - .collect(), - } - } - - #[test] - fn converts_kebab_case_names_to_identifiers() { - assert_eq!(host_function_identifier("orders"), "orders"); - assert_eq!(host_function_identifier("order-store"), "orderStore"); - assert_eq!( - host_function_identifier("list-open-orders"), - "listOpenOrders" - ); - } - - #[test] - fn is_empty_without_host_functions() { - assert_eq!(inline_host_functions_prefix(&BTreeMap::new()), ""); - } - - #[test] - fn defines_one_group_per_collection_on_a_single_line() { - let host_functions = BTreeMap::from([( - String::from("order-store"), - collection("order-store", &["list-orders"]), - )]); - let prefix = inline_host_functions_prefix(&host_functions); - assert!( - !prefix.contains('\n'), - "prefix must not shift guest line numbers" - ); - assert!(prefix.ends_with( - r#"({"orderStore":{"command":"agentos-order-store","functions":[["listOrders","list-orders"]]}});"# - )); - } -} diff --git a/docs/content/docs/host-functions.mdx b/docs/content/docs/host-functions.mdx index 819abe25e7..74546a67cf 100644 --- a/docs/content/docs/host-functions.mdx +++ b/docs/content/docs/host-functions.mdx @@ -4,7 +4,7 @@ description: "Expose trusted host functions to guest programs in agentOS VMs as skill: true --- -Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that inline JavaScript can call directly. +Host functions expose JavaScript functions from your process to code running inside the VM. They run as in-process callbacks, so they are available in the library only, not on hosted VMs. Each function is defined with a Zod input schema and becomes an auto-generated CLI command at `/bin/agentos-{name}`, plus an async function that guest JavaScript can call directly. ## Getting started @@ -66,9 +66,13 @@ Missing required flag: --city ## Call host functions from JavaScript -Inline JavaScript and TypeScript get each collection as a frozen global, and each +Every guest Node process gets each collection as a frozen global, and each host function as an async function, so generated code calls your tools like any -other API. Keys written in camelCase become kebab-case commands and camelCase guest +other API. This covers inline `vm.javascript` and `vm.typescript` executions, +scripts started with `vm.process.spawn("node", ...)` or `vm.process.exec`, +`node` run from the guest shell, and child processes of guest Node. The globals +are defined before the entry script and its imports run, so a module can use +them at its top level. Keys written in camelCase become kebab-case commands and camelCase guest identifiers, so `orderStore.listOrders` is `agentos-order-store list-orders` on the CLI and `orderStore.listOrders(input)` in guest JavaScript. diff --git a/docs/content/docs/javascript.mdx b/docs/content/docs/javascript.mdx index 4444772178..aac660617f 100644 --- a/docs/content/docs/javascript.mdx +++ b/docs/content/docs/javascript.mdx @@ -192,7 +192,9 @@ See [Filesystem](/agentos/docs/filesystem), [Processes & Shells](/agentos/docs/p ## Host functions Guest JavaScript calls [host functions](/agentos/docs/host-functions) as async globals, so host -credentials stay outside the VM. Host functions are library-only. +credentials stay outside the VM. The globals exist in every guest Node process, +whether it runs inline code, a spawned script, `node` from the shell, or a child +process. Host functions are library-only. diff --git a/examples/embedded/host-functions-javascript.ts b/examples/embedded/host-functions-javascript.ts index 71cbe9e42a..8c38bb64f3 100644 --- a/examples/embedded/host-functions-javascript.ts +++ b/examples/embedded/host-functions-javascript.ts @@ -31,6 +31,18 @@ try { { output: { capture: "all" } }, ); console.log(result.outcome === "succeeded" ? result.stdout : result.error); + + // Every guest Node process gets the same globals, including scripts started + // with process.spawn(), from the shell, or by another guest process. + await vm.writeFile( + "/app/report.mjs", + `const forecast = await weather.forecast({ city: "Tokyo" }); +console.log(forecast);`, + ); + const report = await vm.process.exec("node /app/report.mjs", { + output: { capture: "all" }, + }); + console.log(report.outcome === "succeeded" ? report.stdout : report.error); } finally { await vm.dispose(); } diff --git a/packages/core/package.json b/packages/core/package.json index cd685e92f1..ae0133f96f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,7 +54,7 @@ "check-types": "tsc --noEmit && tsc -p tsconfig.type-tests.json", "build": "tsc && node scripts/stage-default-software.mjs", "test": "vitest run --exclude '**/*.nightly.test.ts' --reporter=verbose", - "test:unit": "vitest run tests/agentos-package.test.ts tests/allowed-node-builtins.test.ts tests/host-functions-zod.test.ts tests/host-functions.test.ts tests/host-function-command-exec.test.ts tests/host-function-permissions.test.ts tests/cron-manager.test.ts tests/code-mode-execution.test.ts tests/cron-timer-driver.test.ts tests/exec-timeout.test.ts tests/execution-abort.test.ts tests/generated-protocol.test.ts tests/leak-agent-os-processes.test.ts tests/leak-rpc-client.test.ts tests/mount-descriptors.test.ts tests/mount-reconfigure.test.ts tests/options-schema.test.ts tests/output-replay.test.ts tests/public-api-exports.test.ts tests/root-filesystem-descriptors.test.ts tests/runtime-compat-mount.test.ts tests/sidecar-client.test.ts tests/sidecar-exit.test.ts tests/sidecar-permission-descriptors.test.ts tests/software-install.test.ts tests/wasm-permission-tiers.test.ts --fileParallelism=false", + "test:unit": "vitest run tests/agentos-package.test.ts tests/allowed-node-builtins.test.ts tests/host-functions-zod.test.ts tests/host-functions.test.ts tests/host-function-command-exec.test.ts tests/host-function-permissions.test.ts tests/host-function-globals.test.ts tests/cron-manager.test.ts tests/code-mode-execution.test.ts tests/cron-timer-driver.test.ts tests/exec-timeout.test.ts tests/execution-abort.test.ts tests/generated-protocol.test.ts tests/leak-agent-os-processes.test.ts tests/leak-rpc-client.test.ts tests/mount-descriptors.test.ts tests/mount-reconfigure.test.ts tests/options-schema.test.ts tests/output-replay.test.ts tests/public-api-exports.test.ts tests/root-filesystem-descriptors.test.ts tests/runtime-compat-mount.test.ts tests/sidecar-client.test.ts tests/sidecar-exit.test.ts tests/sidecar-permission-descriptors.test.ts tests/software-install.test.ts tests/wasm-permission-tiers.test.ts --fileParallelism=false", "test:pr": "pnpm test:unit", "test:nightly": "vitest run tests/*.nightly.test.ts --reporter=verbose --passWithNoTests" }, diff --git a/packages/core/tests/host-function-globals.nightly.test.ts b/packages/core/tests/host-function-globals.nightly.test.ts new file mode 100644 index 0000000000..256d500007 --- /dev/null +++ b/packages/core/tests/host-function-globals.nightly.test.ts @@ -0,0 +1,68 @@ +// Nightly: launches Node through the guest shell, which needs the default +// software bundle (`sh` from coreutils). +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod"; +import { AgentOs } from "../src/index.js"; +import { ALLOW_ALL_VM_PERMISSIONS } from "./helpers/permissions.js"; + +const orderStore = { + listOrders: { + inputSchema: z.object({ status: z.string() }), + execute: ({ status }: { status: string }) => ({ + orders: [{ id: "order-1", status }], + }), + }, +}; + +const EXPECTED_RESULT = { orders: [{ id: "order-1", status: "open" }] }; + +function lastJsonLine(stdout: string): unknown { + const lines = stdout.trim().split("\n"); + return JSON.parse(lines[lines.length - 1] ?? ""); +} + +describe("host-function globals in shell-launched Node processes", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose(); + vm = undefined; + }); + + test("node run from the guest shell can call host functions", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.writeFile( + "/app/main.mjs", + `const result = await orderStore.listOrders({ status: "open" }); +console.log(JSON.stringify(result));`, + ); + + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const { pid } = await vm.process.spawn("sh", ["-c", "node /app/main.mjs"], { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += decoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(pid); + expect({ exitCode: exit.exitCode, stderr }, stdout).toEqual({ + exitCode: 0, + stderr: "", + }); + expect(lastJsonLine(stdout)).toEqual(EXPECTED_RESULT); + + const exec = await vm.process.exec("node /app/main.mjs", { + output: { capture: "all" }, + }); + expect(exec.outcome, JSON.stringify(exec)).toBe("succeeded"); + if (exec.outcome !== "succeeded") return; + expect(lastJsonLine(exec.stdout ?? "")).toEqual(EXPECTED_RESULT); + }, 120_000); +}); diff --git a/packages/core/tests/host-function-globals.test.ts b/packages/core/tests/host-function-globals.test.ts new file mode 100644 index 0000000000..80edefc4c9 --- /dev/null +++ b/packages/core/tests/host-function-globals.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { z } from "zod"; +import { AgentOs } from "../src/index.js"; +import { ALLOW_ALL_VM_PERMISSIONS } from "./helpers/permissions.js"; + +// Every guest Node process in a VM with host functions gets one frozen global +// per collection, not only inline `vm.javascript.*` executions. These tests +// launch Node the other ways a guest can: a direct spawn and a child process +// of guest Node. The shell launch paths need the default software bundle, so +// they live in host-function-globals.nightly.test.ts. + +const orderStore = { + listOrders: { + inputSchema: z.object({ status: z.string() }), + execute: ({ status }: { status: string }) => ({ + orders: [{ id: "order-1", status }], + }), + }, +}; + +const EXPECTED_RESULT = { orders: [{ id: "order-1", status: "open" }] }; + +const MAIN_SCRIPT = ` +const result = await orderStore.listOrders({ status: "open" }); +console.log(JSON.stringify(result)); +`; + +async function runNode( + vm: AgentOs, + command: string, + args: string[], +): Promise<{ exitCode?: number; stdout: string; stderr: string }> { + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const { pid } = await vm.process.spawn(command, args, { + onStdout: (chunk) => { + stdout += decoder.decode(chunk); + }, + onStderr: (chunk) => { + stderr += decoder.decode(chunk); + }, + }); + const exit = await vm.process.wait(pid); + return { exitCode: exit.exitCode, stdout, stderr }; +} + +function lastJsonLine(stdout: string): unknown { + const lines = stdout.trim().split("\n"); + return JSON.parse(lines[lines.length - 1] ?? ""); +} + +describe("host-function globals in guest Node processes", () => { + let vm: AgentOs | undefined; + + afterEach(async () => { + await vm?.dispose(); + vm = undefined; + }); + + test("spawned and child Node processes can call host functions", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.writeFile("/app/main.mjs", MAIN_SCRIPT); + await vm.writeFile( + "/app/main.cjs", + `orderStore.listOrders({ status: "open" }).then((result) => { + console.log(JSON.stringify(result)); + });`, + ); + // A static import evaluates before the importing module's body, so this + // also covers modules that use the global at their top level. + await vm.writeFile( + "/app/orders.mjs", + `export const result = await orderStore.listOrders({ status: "open" });`, + ); + await vm.writeFile( + "/app/import.mjs", + `import { result } from "./orders.mjs"; +console.log(JSON.stringify(result));`, + ); + await vm.writeFile( + "/app/parent.mjs", + `import { execFile } from "node:child_process"; +execFile("node", ["/app/main.mjs"], (error, stdout, stderr) => { + if (error) { + console.error(stderr || error.message); + process.exit(1); + } + process.stdout.write(stdout); +});`, + ); + + const cases: Array<[string, string, string[]]> = [ + ["process.spawn ESM", "node", ["/app/main.mjs"]], + ["process.spawn CommonJS", "node", ["/app/main.cjs"]], + ["process.spawn static import", "node", ["/app/import.mjs"]], + ["guest child_process", "node", ["/app/parent.mjs"]], + ]; + for (const [label, command, args] of cases) { + const result = await runNode(vm, command, args); + expect( + { label, exitCode: result.exitCode, stderr: result.stderr }, + `stdout: ${result.stdout}`, + ).toEqual({ label, exitCode: 0, stderr: "" }); + expect(lastJsonLine(result.stdout), label).toEqual(EXPECTED_RESULT); + } + + // The generated CLI keeps working alongside the globals. + const cli = await runNode(vm, "agentos-order-store", [ + "list-orders", + "--status", + "open", + ]); + expect(cli.exitCode, cli.stderr).toBe(0); + expect(JSON.parse(cli.stdout)).toEqual({ + ok: true, + result: EXPECTED_RESULT, + }); + }, 120_000); + + test("inline executions keep the globals across a retained context", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { orderStore }, + }); + await vm.createContext("orders"); + for (let run = 0; run < 2; run++) { + const result = await vm.javascript.evaluate( + `orderStore.listOrders({ status: "open" })`, + { contextId: "orders" }, + ); + expect(result, `run ${run}`).toMatchObject({ + exitCode: 0, + value: EXPECTED_RESULT, + }); + expect(JSON.stringify(result), `run ${run}`).not.toContain( + "was not defined", + ); + } + }, 60_000); + + test("the hostFunction permission still applies to the globals", async () => { + vm = await AgentOs.create({ + permissions: { + ...ALLOW_ALL_VM_PERMISSIONS, + hostFunction: { default: "deny", rules: [] }, + }, + hostFunctions: { orderStore }, + }); + await vm.writeFile( + "/app/denied.mjs", + `try { + await orderStore.listOrders({ status: "open" }); + console.log("ALLOWED"); +} catch (error) { + console.log("DENIED " + error.message); +}`, + ); + + const result = await runNode(vm, "node", ["/app/denied.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("DENIED"); + expect(result.stdout).toContain("hostFunction.invoke"); + expect(result.stdout).toContain("order-store:list-orders"); + }, 60_000); + + test("no globals are defined without host functions", async () => { + vm = await AgentOs.create({ permissions: ALLOW_ALL_VM_PERMISSIONS }); + await vm.writeFile( + "/app/types.mjs", + `console.log(JSON.stringify(typeof globalThis.orderStore));`, + ); + + const result = await runNode(vm, "node", ["/app/types.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(lastJsonLine(result.stdout)).toBe("undefined"); + }, 60_000); + + test("a collection named like an existing global is skipped with a warning", async () => { + vm = await AgentOs.create({ + permissions: ALLOW_ALL_VM_PERMISSIONS, + hostFunctions: { + process: { + status: { + inputSchema: z.object({}), + execute: () => ({ ok: true }), + }, + }, + orderStore, + }, + }); + await vm.writeFile( + "/app/collision.mjs", + `console.log(JSON.stringify({ + processStatus: typeof process.status, + cwd: typeof process.cwd, + orderStore: typeof orderStore.listOrders, +}));`, + ); + + const result = await runNode(vm, "node", ["/app/collision.mjs"]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain( + 'agentos: host function group "process" was not defined because globalThis.process already exists', + ); + expect(lastJsonLine(result.stdout)).toEqual({ + processStatus: "undefined", + cwd: "function", + orderStore: "function", + }); + }, 60_000); +}); diff --git a/secure-exec/docs/content/docs/host-functions.mdx b/secure-exec/docs/content/docs/host-functions.mdx index 8b2585b9ce..de61631b99 100644 --- a/secure-exec/docs/content/docs/host-functions.mdx +++ b/secure-exec/docs/content/docs/host-functions.mdx @@ -24,6 +24,8 @@ Arguments from the guest are validated before your code runs. ## Call them from guest code Inside the VM each collection is a global object and each function is async. +The globals exist in every guest Node process: inline code, spawned scripts, +`node` run from the shell, and child processes of guest Node.