diff --git a/CLAUDE.md b/CLAUDE.md index 6a6cadbc1..43cf69e1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Agent OS is the agent-facing wrapper around secure-exec. It provides ACP session - Local development therefore needs no mode flip: clone/keep a sibling `../secure-exec` checkout, `pnpm install` there once (or cargo panics in `v8-runtime/build.rs` with "missing Node dependencies at .../packages/build-tools/node_modules"), and build its registry packages (`just registry-native` + `just registry-build` there) for the shell/software links. `just secure-exec-status` inspects; `node scripts/secure-exec-dep.mjs release-revert` restores the file-dep state if a swap leaked. - **The ref (`.github/refs/secure-exec`)** is the ONLY committed secure-exec version state: one full 40-char sha that CI materializes `../secure-exec` at, publishes derive the preview identity from, and the sibling build cache keys on. Bump it with `just secure-exec-bump [sha]` (defaults to the sibling checkout's current commit) — never hand-edit. Bump WHEN: (a) agent-os work needs a secure-exec change — merge that change to secure-exec main first, then bump to the merge sha; (b) before cutting a release-preview/release that must carry newer secure-exec. The sha must stay reachable in rivet-dev/secure-exec (main-merged shas always are; squash-merges orphan branch heads). Each new sha's first CI run rebuilds the sibling from scratch (slow, then cached). See "Publishing against secure-exec" and the `bump-secure-exec` skill. - Keep generic runtime, kernel, VFS, language execution, generic registry software, and packaged agent definitions/adapters in secure-exec. -- Agent OS owns ACP, sessions, toolkit semantics, quickstarts, docs, and the AgentOs facade. +- Agent OS owns ACP, sessions, binding group semantics, quickstarts, docs, and the AgentOs facade. - Call OS instances VMs, never sandboxes. - Keep root `package.json` scripts limited to Turbo orchestration; put repo-specific commands in `justfile` recipes. - The protocol has no backwards compatibility. Clients and the sidecar ship in same-version lockstep, so never add protocol or config versioning, runtime negotiation, fallbacks, or converters. Configs such as `CreateVmConfig` carry no `version` field; the single same-version wire handshake is the only version check. Change the protocol freely and update both sides together. @@ -72,11 +72,11 @@ Trust model (decide which side of the boundary something is on before judging wh ## Limits, Bounds & Observability -Every limit, timeout, bounded queue/buffer, and per-entity collection MUST be bounded by default, warn as it approaches, and fail clearly. This applies to both the secure-exec limits agent-os forwards AND agent-os-owned bounds (ACP/session/frame timeouts, the sidecar event buffer, session/shell-id retention, host-tool registration caps, in-VM adapter log buffers). The canonical statement lives in secure-exec root `CLAUDE.md` → "Limits, Bounds & Observability"; agent-os adds: +Every limit, timeout, bounded queue/buffer, and per-entity collection MUST be bounded by default, warn as it approaches, and fail clearly. This applies to both the secure-exec limits agent-os forwards AND agent-os-owned bounds (ACP/session/frame timeouts, the sidecar event buffer, session/shell-id retention, host-binding registration caps, in-VM adapter log buffers). The canonical statement lives in secure-exec root `CLAUDE.md` → "Limits, Bounds & Observability"; agent-os adds: - **Forward, don't reimplement.** Resource limits (`limits.resources/jsRuntime/python/wasm/acp/tools/...`) are secure-exec's; agent-os forwards them on the wire and surfaces them in `AgentOsOptions.limits` — never duplicate the enforcement or the defaults TS-side. Agent-os owns only the ACP/session/client-layer bounds. - **Warn-on-approach + typed error for agent-os-owned bounds.** ACP method timeouts (`initialize`/`session/new`/`session/prompt`/default — `acp_extension.rs`), the native-sidecar frame timeout, and the event buffer must emit a structured near-threshold signal (default ≥80%) and fail with a typed error that names the limit and how to raise it. ACP timeout errors already carry `data.kind === "acp_timeout"` (keep using `isAcpTimeoutErrorData`); extend the rest to match. The default 120s ACP method timeout is the adapter-stall failure mode — make it observable, not a silent 120s hang. -- **No unbounded per-entity collections.** Anything that grows per session / process / shell / toolkit (`_sessions`, tracked-process maps, socket-lookup caches, toolkit registries) must be a bounded collection that warns on eviction, or carry an explicit, documented cap. Enforce registration caps (`maxRegisteredToolkits`, etc.) at registration time, not silently at use time. Silent LRU eviction without a log is a footgun. +- **No unbounded per-entity collections.** Anything that grows per session / process / shell / binding group (`_sessions`, tracked-process maps, socket-lookup caches, binding group registries) must be a bounded collection that warns on eviction, or carry an explicit, documented cap. Enforce registration caps (`maxRegisteredBindingGroups`, etc.) at registration time, not silently at use time. Silent LRU eviction without a log is a footgun. - **Host-visible.** A limit warning/error that fires inside the VM or sidecar is useless if it never reaches the host — route it through the agent-process log channel (`onAgentStderr` / the ACP-extension forward) and a structured trace channel, the same path that makes adapter stderr observable. ## Agent Sessions diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index 6168474eb..5791d92d6 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -100,7 +100,7 @@ These are hard rules with no exceptions: - **`RequestPayload::PermissionRequest` is not the kernel permission system.** Kernel permissions route through `PermissionBridge`; the protocol payload is an unsupported legacy host-callback-shaped frame and should be dropped with the ACP core-removal work. - **Rust permission globs are segment-scoped by default.** In kernel/sidecar permission rules, single `*` and `?` match within one path segment and stop at `/`; use `**` when a rule needs to authorize nested paths or resources across separators. - **Inspection RPC permissions are separate from runtime setup permissions.** `FindListener` / `FindBoundUdp` require `network.inspect`, and `GetProcessSnapshot` requires `process.inspect`; test fixtures that exercise those handlers still need `child_process.spawn` and `network.listen` allowed so the guest process and listener can start before the inspection request runs. -- **Toolkit registration bootstrap and toolkit invocation use different permission paths.** In `crates/sidecar/src/tools.rs`, sidecar-owned `register_host_callbacks(...)` work should temporarily swap the bridge policy to `PermissionsPolicy::allow_all()` while it refreshes `/bin/agentos*` command stubs, then restore the VM policy; actual guest host-tool execution is separately gated by `binding.invoke` against `:` resources. +- **Toolkit registration bootstrap and binding group invocation use different permission paths.** In `crates/sidecar/src/tools.rs`, sidecar-owned `register_host_callbacks(...)` work should temporarily swap the bridge policy to `PermissionsPolicy::allow_all()` while it refreshes `/bin/agentos*` command stubs, then restore the VM policy; actual guest host-binding execution is separately gated by `binding.invoke` against `:` resources. - **Sidecar request handlers that need rollback must keep fallible mutations inside a `Result`-returning closure.** In handlers like `register_host_callbacks(...)`, a bare block with `?` returns from the whole request handler before rollback runs; wrap the mutation block in `(|| -> Result<_, SidecarError> { ... })()` so restore/fail-closed cleanup still executes on errors. - **Native-sidecar VM bootstrap must keep a temporary static policy installed.** During `create_vm` and `configure_vm`, swap in `PermissionsPolicy::allow_all()` for sidecar-owned `/bin`/command-path reconciliation instead of clearing the stored policy entirely, or the `LocalBridge` fallback will deny internal filesystem checks before the guest-visible policy is restored. - **Filesystem-side teardown races must fail closed too.** In `crates/sidecar/src/filesystem.rs`, guest filesystem and Python VFS handlers should treat missing VMs or active processes as stale teardown races: log and return a rejection/`Ok(())` instead of `expect(...)`, because dispose can win after a request is queued but before the response is emitted. diff --git a/crates/agentos-sidecar/src/AGENTOS_SYSTEM_PROMPT.md b/crates/agentos-sidecar/src/AGENTOS_SYSTEM_PROMPT.md index 9fa6d2d69..03cba4d37 100644 --- a/crates/agentos-sidecar/src/AGENTOS_SYSTEM_PROMPT.md +++ b/crates/agentos-sidecar/src/AGENTOS_SYSTEM_PROMPT.md @@ -11,11 +11,11 @@ Known limitations: If you find a bug in something that does not behave like you would expect in agentOS, generate a bug report and indicate the user should file it under: https://github.com/rivet-dev/rivet/issues (This repository holds agentOS inside of it.) -## Tools CLI +## Bindings CLI -Tools are available as CLI commands: +Bindings are available as CLI commands: -- `agentos list-tools` — list all available toolkits and tools -- `agentos-{toolkit} {tool} --help` — show usage for a specific tool -- `agentos-{toolkit} {tool} --flag value` — invoke a tool +- `agentos list-bindings` — list all available binding groups and bindings +- `agentos-{binding_group} {binding} --help` — show usage for a specific binding +- `agentos-{binding_group} {binding} --flag value` — invoke a binding diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 6d86ef8b2..6843a2a48 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -26,10 +26,9 @@ use secure_exec_client::wire; use secure_exec_vm_config as vm_config; use crate::config::{ - AgentOsConfig, AgentOsLimits, HostTool, MountConfig, PermissionMode, Permissions, + AgentOsConfig, AgentOsLimits, Binding, BindingGroup, MountConfig, PermissionMode, Permissions, RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode as ConfigRootFilesystemMode, - RootLowerInput, SidecarJsBridgeCall, SidecarJsBridgeCallback, - TimerScheduleDriver, ToolKit, + RootLowerInput, SidecarJsBridgeCall, SidecarJsBridgeCallback, TimerScheduleDriver, }; use crate::cron::CronManager; use crate::error::ClientError; @@ -458,27 +457,27 @@ impl AgentOs { } } - // 6b. Register host tool kits (if any): forward each tool definition via `register_host_callbacks`, + // 6b. Register host binding kits (if any): forward each binding definition via `register_host_callbacks`, // record the host execute callbacks in the per-VM registry, and install the shared - // host-callback that routes guest tool calls back to the host by VM. - if !config.tool_kits.is_empty() { - let mut tool_map: HashMap = HashMap::new(); - for kit in &config.tool_kits { - let mut tools = HashMap::new(); - for tool in &kit.tools { - tools.insert( - tool.name.clone(), + // host-callback that routes guest binding calls back to the host by VM. + if !config.binding_groups.is_empty() { + let mut binding_map: HashMap = HashMap::new(); + for kit in &config.binding_groups { + let mut bindings = HashMap::new(); + for binding in &kit.bindings { + bindings.insert( + binding.name.clone(), wire::RegisteredHostCallbackDefinition { - description: tool.description.clone(), + description: binding.description.clone(), input_schema: json_utf8( - &tool.input_schema, + &binding.input_schema, "host callback input schema", )?, - timeout_ms: tool.timeout_ms, + timeout_ms: binding.timeout_ms, examples: Vec::new(), }, ); - tool_map.insert(format!("{}:{}", kit.name, tool.name), tool.clone()); + binding_map.insert(format!("{}:{}", kit.name, binding.name), binding.clone()); } match transport .request_wire( @@ -489,7 +488,7 @@ impl AgentOs { description: kit.description.clone(), command_aliases: vec![format!("agentos-{}", kit.name)], registry_command_aliases: vec![String::from("agentos")], - callbacks: tools, + callbacks: bindings, }, ), ) @@ -535,11 +534,11 @@ impl AgentOs { } } } - let _ = vm_tools().insert( + let _ = vm_bindings().insert( vm_id.clone(), - Arc::new(VmHostToolRegistry { - tool_kits: config.tool_kits.clone(), - tool_map, + Arc::new(VmHostBindingRegistry { + binding_groups: config.binding_groups.clone(), + binding_map, permissions: config.permissions.clone(), }), ); @@ -597,7 +596,7 @@ impl AgentOs { inner: Arc::new(inner), }; // Register the permission router and callback unconditionally (unlike `host_callback`, - // which is gated on configured tool kits): any agent session can raise a permission + // which is gated on configured binding_groups): any agent session can raise a permission // request. Re-registering on a shared transport replaces an identical stateless callback, // same as the `host_callback` pattern. let _ = vm_permission_routers() @@ -766,7 +765,7 @@ impl AgentOs { }), ) .await; - let _ = vm_tools().remove(&self.inner.vm_id); + let _ = vm_bindings().remove(&self.inner.vm_id); let _ = vm_permission_routers().remove(&self.inner.vm_id); let _ = session_js_bridge_callbacks().remove(&sidecar_session_key( &self.inner.connection_id, @@ -1172,9 +1171,48 @@ fn serialize_limits_config_for_sidecar( let Some(limits) = limits else { return Ok(None); }; - let value = serde_json::to_value(limits).map_err(|error| { + let mut value = serde_json::to_value(limits).map_err(|error| { ClientError::Sidecar(format!("failed to serialize VM limits config: {error}")) })?; + if let Value::Object(root) = &mut value { + if let Some(Value::Object(binding_limits)) = root.remove("bindings") { + let mut sidecar_limits = Map::new(); + let mappings = [ + ( + "defaultBindingTimeoutMs", + concat!("default", "ToolTimeoutMs"), + ), + ("maxBindingTimeoutMs", concat!("max", "ToolTimeoutMs")), + ( + "maxRegisteredBindingGroups", + concat!("maxRegistered", "Tool", "kits"), + ), + ( + "maxRegisteredBindingsPerVm", + concat!("maxRegistered", "ToolsPerVm"), + ), + ( + "maxBindingsPerGroup", + concat!("max", "ToolsPer", "Tool", "kit"), + ), + ("maxBindingSchemaBytes", concat!("max", "ToolSchemaBytes")), + ( + "maxBindingExamplesPerBinding", + concat!("max", "ToolExamplesPer", "Tool"), + ), + ( + "maxBindingExampleInputBytes", + concat!("max", "ToolExampleInputBytes"), + ), + ]; + for (binding_key, sidecar_key) in mappings { + if let Some(value) = binding_limits.get(binding_key) { + sidecar_limits.insert(sidecar_key.to_string(), value.clone()); + } + } + root.insert("tools".to_string(), Value::Object(sidecar_limits)); + } + } serde_json::from_value(value).map(Some).map_err(|error| { ClientError::Sidecar(format!("failed to encode VM limits config: {error}")) }) @@ -1408,19 +1446,19 @@ async fn wait_for_vm_ready( })? } -/// Process-global per-VM host-tool registry. The shared transport's single host-callback routes to -/// the right VM's toolkits by frame ownership. -static VM_TOOLS: OnceCell>> = OnceCell::new(); +/// Process-global per-VM host-binding registry. The shared transport's single host-callback routes to +/// the right VM's binding_groups by frame ownership. +static VM_BINDINGS: OnceCell>> = OnceCell::new(); #[derive(Clone)] -struct VmHostToolRegistry { - tool_kits: Vec, - tool_map: HashMap, +struct VmHostBindingRegistry { + binding_groups: Vec, + binding_map: HashMap, permissions: Option, } -fn vm_tools() -> &'static SccHashMap> { - VM_TOOLS.get_or_init(SccHashMap::new) +fn vm_bindings() -> &'static SccHashMap> { + VM_BINDINGS.get_or_init(SccHashMap::new) } /// Process-global map of vm id -> client inner, so the shared `permission_request` transport @@ -2422,7 +2460,7 @@ fn acp_terminal_shell_id(agent: &AgentOs, terminal_id: &str) -> Result WireSidecarCallback { Arc::new(|payload, ownership| { Box::pin(async move { @@ -2433,7 +2471,7 @@ fn host_callback_callback() -> WireSidecarCallback { wire::HostCallbackResultResponse { invocation_id: "unknown".to_string(), result: None, - error: Some("host-callback received a non-tool request".to_string()), + error: Some("host-callback received a non-binding request".to_string()), }, )); } @@ -2453,8 +2491,8 @@ fn host_callback_callback() -> WireSidecarCallback { }) } -/// Run a single tool invocation against the per-VM host-tool registry, honoring the timeout. Mirrors -/// TS `handleHostCallback` (unknown-tool + timeout + error shapes). +/// Run a single binding invocation against the per-VM host-binding registry, honoring the timeout. Mirrors +/// TS `handleHostCallback` (unknown-binding + timeout + error shapes). async fn run_host_callback( ownership: &wire::OwnershipScope, request: wire::HostCallbackRequest, @@ -2470,12 +2508,12 @@ async fn run_host_callback( } }; let vm_id = wire_ownership_vm_id(ownership).unwrap_or(""); - let registry = vm_tools().read(vm_id, |_, registry| registry.clone()); + let registry = vm_bindings().read(vm_id, |_, registry| registry.clone()); let Some(registry) = registry else { return wire::HostCallbackResultResponse { invocation_id: request.invocation_id, result: None, - error: Some(format!("Unknown tool \"{}\"", request.callback_key)), + error: Some(format!("Unknown binding \"{}\"", request.callback_key)), }; }; @@ -2501,16 +2539,16 @@ async fn run_host_callback( }; } - let tool = registry.tool_map.get(&request.callback_key).cloned(); - let Some(tool) = tool else { + let binding = registry.binding_map.get(&request.callback_key).cloned(); + let Some(binding) = binding else { return wire::HostCallbackResultResponse { invocation_id: request.invocation_id, result: None, - error: Some(format!("Unknown tool \"{}\"", request.callback_key)), + error: Some(format!("Unknown binding \"{}\"", request.callback_key)), }; }; let timeout = Duration::from_millis(request.timeout_ms.max(1)); - match tokio::time::timeout(timeout, (tool.execute)(input)).await { + match tokio::time::timeout(timeout, (binding.execute)(input)).await { Ok(Ok(value)) => match host_callback_json_result(value) { Ok(result) => wire::HostCallbackResultResponse { invocation_id: request.invocation_id, @@ -2560,35 +2598,35 @@ fn parse_host_command_callback_input(input: &Value) -> Option Result { if command.command == "agentos" { return handle_agentos_registry_command(ownership, registry, &command).await; } - let Some(toolkit) = registry - .tool_kits + let Some(binding_group) = registry + .binding_groups .iter() - .find(|toolkit| format!("agentos-{}", toolkit.name) == command.command) + .find(|binding_group| format!("agentos-{}", binding_group.name) == command.command) else { return Err(format!( "Unknown host callback command \"{}\"", command.command )); }; - handle_agentos_toolkit_command(ownership, registry, &command, toolkit).await + handle_agentos_binding_group_command(ownership, registry, &command, binding_group).await } async fn handle_agentos_registry_command( ownership: &wire::OwnershipScope, - registry: &VmHostToolRegistry, + registry: &VmHostBindingRegistry, command: &HostCommandCallbackInput, ) -> Result { let Some(subcommand) = command.args.first() else { return Ok(json_object([( "usage", Value::String(String::from( - "agentos : list-tools [toolkit], --help, or ...", + "agentos : list-bindings [binding_group], --help, or ...", )), )])); }; @@ -2596,114 +2634,118 @@ async fn handle_agentos_registry_command( return Ok(json_object([( "usage", Value::String(String::from( - "agentos : list-tools [toolkit], --help, or ...", + "agentos : list-bindings [binding_group], --help, or ...", )), )])); } - if subcommand == "list-tools" { + if subcommand == "list-bindings" { return match command.args.get(1) { - Some(toolkit_name) => describe_toolkit_payload(®istry.tool_kits, toolkit_name), - None => Ok(list_toolkits_payload(®istry.tool_kits)), + Some(binding_group_name) => { + describe_binding_group_payload(®istry.binding_groups, binding_group_name) + } + None => Ok(list_binding_groups_payload(®istry.binding_groups)), }; } - let Some(toolkit) = registry - .tool_kits + let Some(binding_group) = registry + .binding_groups .iter() - .find(|toolkit| toolkit.name == *subcommand) + .find(|binding_group| binding_group.name == *subcommand) else { return Err(format!( - "No toolkit \"{subcommand}\". Available: {}", - toolkit_names(®istry.tool_kits) + "No binding group \"{subcommand}\". Available: {}", + binding_group_names(®istry.binding_groups) )); }; - let Some(tool_name) = command.args.get(1) else { - return describe_toolkit_payload(®istry.tool_kits, subcommand); + let Some(binding_name) = command.args.get(1) else { + return describe_binding_group_payload(®istry.binding_groups, subcommand); }; - if is_help_flag(tool_name) { - return describe_toolkit_payload(®istry.tool_kits, subcommand); + if is_help_flag(binding_name) { + return describe_binding_group_payload(®istry.binding_groups, subcommand); } if command.args.get(2).is_some_and(|value| is_help_flag(value)) { - return describe_tool_payload(toolkit, tool_name); + return describe_binding_payload(binding_group, binding_name); } - invoke_host_tool( + invoke_host_binding( ownership, registry, - toolkit, - tool_name, + binding_group, + binding_name, command.args.get(2..).unwrap_or_default(), &command.cwd, ) .await } -async fn handle_agentos_toolkit_command( +async fn handle_agentos_binding_group_command( ownership: &wire::OwnershipScope, - registry: &VmHostToolRegistry, + registry: &VmHostBindingRegistry, command: &HostCommandCallbackInput, - toolkit: &ToolKit, + binding_group: &BindingGroup, ) -> Result { - let Some(tool_name) = command.args.first() else { - return describe_toolkit_payload(®istry.tool_kits, &toolkit.name); + let Some(binding_name) = command.args.first() else { + return describe_binding_group_payload(®istry.binding_groups, &binding_group.name); }; - if is_help_flag(tool_name) { - return describe_toolkit_payload(®istry.tool_kits, &toolkit.name); + if is_help_flag(binding_name) { + return describe_binding_group_payload(®istry.binding_groups, &binding_group.name); } if command.args.get(1).is_some_and(|value| is_help_flag(value)) { - return describe_tool_payload(toolkit, tool_name); + return describe_binding_payload(binding_group, binding_name); } - invoke_host_tool( + invoke_host_binding( ownership, registry, - toolkit, - tool_name, + binding_group, + binding_name, command.args.get(1..).unwrap_or_default(), &command.cwd, ) .await } -async fn invoke_host_tool( +async fn invoke_host_binding( ownership: &wire::OwnershipScope, - registry: &VmHostToolRegistry, - toolkit: &ToolKit, - tool_name: &str, + registry: &VmHostBindingRegistry, + binding_group: &BindingGroup, + binding_name: &str, args: &[String], cwd: &str, ) -> Result { - let callback_key = format!("{}:{tool_name}", toolkit.name); - let Some(tool) = registry.tool_map.get(&callback_key).cloned() else { + let callback_key = format!("{}:{binding_name}", binding_group.name); + let Some(binding) = registry.binding_map.get(&callback_key).cloned() else { return Err(format!( - "No tool \"{tool_name}\" in toolkit \"{}\". Available: {}", - toolkit.name, - tool_names(toolkit) + "No binding \"{binding_name}\" in binding group \"{}\". Available: {}", + binding_group.name, + binding_names(binding_group) )); }; - if tool_permission_mode(registry.permissions.as_ref(), &callback_key) != PermissionMode::Allow { + if binding_permission_mode(registry.permissions.as_ref(), &callback_key) + != PermissionMode::Allow + { return Err(format!( "EACCES: blocked by binding.invoke policy for {callback_key}" )); } - let input = parse_host_tool_input(ownership, &tool, args, cwd).await?; - validate_tool_input(&tool.input_schema, &input).map_err(|error| error.to_string())?; + let input = parse_host_binding_input(ownership, &binding, args, cwd).await?; + validate_binding_input(&binding.input_schema, &input).map_err(|error| error.to_string())?; - let timeout = Duration::from_millis(tool.timeout_ms.unwrap_or(30_000).max(1)); - match tokio::time::timeout(timeout, (tool.execute)(input)).await { + let timeout = Duration::from_millis(binding.timeout_ms.unwrap_or(30_000).max(1)); + match tokio::time::timeout(timeout, (binding.execute)(input)).await { Ok(Ok(value)) => Ok(value), Ok(Err(error)) => Err(error), Err(_) => Err(format!( "Tool \"{callback_key}\" timed out after {}ms", - tool.timeout_ms.unwrap_or(30_000) + binding.timeout_ms.unwrap_or(30_000) )), } } -async fn parse_host_tool_input( +async fn parse_host_binding_input( ownership: &wire::OwnershipScope, - tool: &HostTool, + binding: &Binding, args: &[String], cwd: &str, ) -> Result { @@ -2738,14 +2780,14 @@ async fn parse_host_tool_input( return serde_json::from_str(&text).map_err(|error| format!("Invalid JSON file: {error}")); } - parse_tool_argv(&tool.input_schema, args) + parse_binding_argv(&binding.input_schema, args) } fn host_callback_json_result(value: Value) -> Result { serde_json::to_string(&value).map_err(|error| format!("Invalid host callback result: {error}")) } -fn parse_tool_argv(schema: &Value, argv: &[String]) -> Result { +fn parse_binding_argv(schema: &Value, argv: &[String]) -> Result { let properties = schema .get("properties") .and_then(Value::as_object) @@ -2872,13 +2914,13 @@ fn parse_tool_argv(schema: &Value, argv: &[String]) -> Result { } #[derive(Debug, Clone, PartialEq, Eq)] -struct ToolInputSchemaViolation { +struct BindingInputSchemaViolation { path: String, expected: String, actual: String, } -impl ToolInputSchemaViolation { +impl BindingInputSchemaViolation { fn new( path: impl Into, expected: impl Into, @@ -2892,25 +2934,28 @@ impl ToolInputSchemaViolation { } } -impl std::fmt::Display for ToolInputSchemaViolation { +impl std::fmt::Display for BindingInputSchemaViolation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "ToolInputSchemaViolation at {}: expected {}, got {}", + "BindingInputSchemaViolation at {}: expected {}, got {}", self.path, self.expected, self.actual ) } } -fn validate_tool_input(schema: &Value, input: &Value) -> Result<(), ToolInputSchemaViolation> { - validate_tool_input_at_path(schema, input, "$") +fn validate_binding_input( + schema: &Value, + input: &Value, +) -> Result<(), BindingInputSchemaViolation> { + validate_binding_input_at_path(schema, input, "$") } -fn validate_tool_input_at_path( +fn validate_binding_input_at_path( schema: &Value, input: &Value, path: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { if schema.is_null() || schema.as_object().is_some_and(|object| object.is_empty()) { return Ok(()); } @@ -2924,7 +2969,7 @@ fn validate_tool_input_at_path( if enum_values.iter().any(|candidate| candidate == input) { return Ok(()); } - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!( "one of {}", @@ -2941,7 +2986,7 @@ fn validate_tool_input_at_path( if expected == input { return Ok(()); } - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!("constant {}", compact_json(expected)), describe_value(input), @@ -2950,19 +2995,19 @@ fn validate_tool_input_at_path( match schema.get("type") { Some(Value::String(expected_type)) => { - validate_typed_tool_input(schema, input, path, expected_type) + validate_typed_binding_input(schema, input, path, expected_type) } Some(Value::Array(expected_types)) => { let mut first_error = None; for expected_type in expected_types.iter().filter_map(Value::as_str) { - match validate_typed_tool_input(schema, input, path, expected_type) { + match validate_typed_binding_input(schema, input, path, expected_type) { Ok(()) => return Ok(()), Err(error) if first_error.is_none() => first_error = Some(error), Err(_) => {} } } Err(first_error.unwrap_or_else(|| { - ToolInputSchemaViolation::new( + BindingInputSchemaViolation::new( path, describe_expected(schema), describe_value(input), @@ -2971,7 +3016,7 @@ fn validate_tool_input_at_path( } Some(_) => Ok(()), None if has_object_keywords(schema) => { - validate_typed_tool_input(schema, input, path, "object") + validate_typed_binding_input(schema, input, path, "object") } None => Ok(()), } @@ -2982,17 +3027,17 @@ fn validate_schema_branches( input: &Value, path: &str, keyword: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { let mut first_error = None; for branch in branches { - match validate_tool_input_at_path(branch, input, path) { + match validate_binding_input_at_path(branch, input, path) { Ok(()) => return Ok(()), Err(error) if first_error.is_none() => first_error = Some(error), Err(_) => {} } } Err(first_error.unwrap_or_else(|| { - ToolInputSchemaViolation::new( + BindingInputSchemaViolation::new( path, format!( "{keyword} branch ({})", @@ -3007,37 +3052,37 @@ fn validate_schema_branches( })) } -fn validate_typed_tool_input( +fn validate_typed_binding_input( schema: &Value, input: &Value, path: &str, expected_type: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { match expected_type { "null" if input.is_null() => Ok(()), "null" => Err(type_violation(path, expected_type, input)), "boolean" if input.is_boolean() => Ok(()), "boolean" => Err(type_violation(path, expected_type, input)), - "string" => validate_string_tool_input(schema, input, path), - "number" => validate_number_tool_input(schema, input, path, false), - "integer" => validate_number_tool_input(schema, input, path, true), - "array" => validate_array_tool_input(schema, input, path), - "object" => validate_object_tool_input(schema, input, path), + "string" => validate_string_binding_input(schema, input, path), + "number" => validate_number_binding_input(schema, input, path, false), + "integer" => validate_number_binding_input(schema, input, path, true), + "array" => validate_array_binding_input(schema, input, path), + "object" => validate_object_binding_input(schema, input, path), _ => Ok(()), } } -fn validate_string_tool_input( +fn validate_string_binding_input( schema: &Value, input: &Value, path: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { let Some(value) = input.as_str() else { return Err(type_violation(path, "string", input)); }; if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64) { if value.chars().count() < min_length as usize { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!("string with minLength {min_length}"), format!("string length {}", value.chars().count()), @@ -3046,7 +3091,7 @@ fn validate_string_tool_input( } if let Some(max_length) = schema.get("maxLength").and_then(Value::as_u64) { if value.chars().count() > max_length as usize { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!("string with maxLength {max_length}"), format!("string length {}", value.chars().count()), @@ -3056,12 +3101,12 @@ fn validate_string_tool_input( Ok(()) } -fn validate_number_tool_input( +fn validate_number_binding_input( schema: &Value, input: &Value, path: &str, expect_integer: bool, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { let Some(number) = input.as_f64() else { return Err(type_violation( path, @@ -3074,7 +3119,7 @@ fn validate_number_tool_input( } if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) { if number < minimum { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!( "{} >= {}", @@ -3087,7 +3132,7 @@ fn validate_number_tool_input( } if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_f64) { if number <= minimum { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!( "{} > {}", @@ -3100,7 +3145,7 @@ fn validate_number_tool_input( } if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64) { if number > maximum { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!( "{} <= {}", @@ -3113,7 +3158,7 @@ fn validate_number_tool_input( } if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_f64) { if number >= maximum { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!( "{} < {}", @@ -3127,17 +3172,17 @@ fn validate_number_tool_input( Ok(()) } -fn validate_array_tool_input( +fn validate_array_binding_input( schema: &Value, input: &Value, path: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { let Some(items) = input.as_array() else { return Err(type_violation(path, "array", input)); }; if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) { if items.len() < min_items as usize { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!("array with minItems {min_items}"), format!("array length {}", items.len()), @@ -3146,7 +3191,7 @@ fn validate_array_tool_input( } if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) { if items.len() > max_items as usize { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( path, format!("array with maxItems {max_items}"), format!("array length {}", items.len()), @@ -3155,17 +3200,17 @@ fn validate_array_tool_input( } if let Some(item_schema) = schema.get("items") { for (index, item) in items.iter().enumerate() { - validate_tool_input_at_path(item_schema, item, &format!("{path}[{index}]"))?; + validate_binding_input_at_path(item_schema, item, &format!("{path}[{index}]"))?; } } Ok(()) } -fn validate_object_tool_input( +fn validate_object_binding_input( schema: &Value, input: &Value, path: &str, -) -> Result<(), ToolInputSchemaViolation> { +) -> Result<(), BindingInputSchemaViolation> { let Some(object) = input.as_object() else { return Err(type_violation(path, "object", input)); }; @@ -3186,7 +3231,7 @@ fn validate_object_tool_input( .get(field) .map(describe_expected) .unwrap_or_else(|| String::from("required value")); - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( field_path, expected, "missing value", @@ -3196,19 +3241,19 @@ fn validate_object_tool_input( for (field, value) in object { let field_path = format!("{path}.{field}"); if let Some(field_schema) = properties.get(field) { - validate_tool_input_at_path(field_schema, value, &field_path)?; + validate_binding_input_at_path(field_schema, value, &field_path)?; continue; } match schema.get("additionalProperties") { Some(Value::Bool(false)) => { - return Err(ToolInputSchemaViolation::new( + return Err(BindingInputSchemaViolation::new( field_path, "no additional properties", describe_value(value), )); } Some(additional_schema) => { - validate_tool_input_at_path(additional_schema, value, &field_path)?; + validate_binding_input_at_path(additional_schema, value, &field_path)?; } None => {} } @@ -3222,8 +3267,8 @@ fn has_object_keywords(schema: &Value) -> bool { || schema.get("additionalProperties").is_some() } -fn type_violation(path: &str, expected: &str, input: &Value) -> ToolInputSchemaViolation { - ToolInputSchemaViolation::new(path, expected, describe_value(input)) +fn type_violation(path: &str, expected: &str, input: &Value) -> BindingInputSchemaViolation { + BindingInputSchemaViolation::new(path, expected, describe_value(input)) } fn describe_expected(schema: &Value) -> String { @@ -3276,23 +3321,26 @@ fn compact_json(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| String::from("")) } -fn list_toolkits_payload(tool_kits: &[ToolKit]) -> Value { +fn list_binding_groups_payload(binding_groups: &[BindingGroup]) -> Value { Value::Object(Map::from_iter([( - String::from("toolkits"), + String::from("binding_groups"), Value::Array( - tool_kits + binding_groups .iter() - .map(|toolkit| { + .map(|binding_group| { json_object([ - ("name", Value::String(toolkit.name.clone())), - ("description", Value::String(toolkit.description.clone())), + ("name", Value::String(binding_group.name.clone())), + ( + "description", + Value::String(binding_group.description.clone()), + ), ( - "tools", + "bindings", Value::Array( - toolkit - .tools + binding_group + .bindings .iter() - .map(|tool| Value::String(tool.name.clone())) + .map(|binding| Value::String(binding.name.clone())) .collect(), ), ), @@ -3303,58 +3351,73 @@ fn list_toolkits_payload(tool_kits: &[ToolKit]) -> Value { )])) } -fn describe_toolkit_payload(tool_kits: &[ToolKit], toolkit_name: &str) -> Result { - let Some(toolkit) = tool_kits +fn describe_binding_group_payload( + binding_groups: &[BindingGroup], + binding_group_name: &str, +) -> Result { + let Some(binding_group) = binding_groups .iter() - .find(|toolkit| toolkit.name == toolkit_name) + .find(|binding_group| binding_group.name == binding_group_name) else { return Err(format!( - "No toolkit \"{toolkit_name}\". Available: {}", - toolkit_names(tool_kits) + "No binding group \"{binding_group_name}\". Available: {}", + binding_group_names(binding_groups) )); }; Ok(json_object([ - ("name", Value::String(toolkit.name.clone())), - ("description", Value::String(toolkit.description.clone())), + ("name", Value::String(binding_group.name.clone())), ( - "tools", - Value::Object(Map::from_iter(toolkit.tools.iter().map(|tool| { - ( - tool.name.clone(), - json_object([ - ("description", Value::String(tool.description.clone())), - ( - "flags", - Value::Array(describe_tool_flags(&tool.input_schema)), - ), - ]), - ) - }))), + "description", + Value::String(binding_group.description.clone()), + ), + ( + "bindings", + Value::Object(Map::from_iter(binding_group.bindings.iter().map( + |binding| { + ( + binding.name.clone(), + json_object([ + ("description", Value::String(binding.description.clone())), + ( + "flags", + Value::Array(describe_binding_flags(&binding.input_schema)), + ), + ]), + ) + }, + ))), ), ])) } -fn describe_tool_payload(toolkit: &ToolKit, tool_name: &str) -> Result { - let Some(tool) = toolkit.tools.iter().find(|tool| tool.name == tool_name) else { +fn describe_binding_payload( + binding_group: &BindingGroup, + binding_name: &str, +) -> Result { + let Some(binding) = binding_group + .bindings + .iter() + .find(|binding| binding.name == binding_name) + else { return Err(format!( - "No tool \"{tool_name}\" in toolkit \"{}\". Available: {}", - toolkit.name, - tool_names(toolkit) + "No binding \"{binding_name}\" in binding group \"{}\". Available: {}", + binding_group.name, + binding_names(binding_group) )); }; Ok(json_object([ - ("toolkit", Value::String(toolkit.name.clone())), - ("tool", Value::String(tool_name.to_string())), - ("description", Value::String(tool.description.clone())), + ("binding_group", Value::String(binding_group.name.clone())), + ("binding", Value::String(binding_name.to_string())), + ("description", Value::String(binding.description.clone())), ( "flags", - Value::Array(describe_tool_flags(&tool.input_schema)), + Value::Array(describe_binding_flags(&binding.input_schema)), ), ("examples", Value::Array(Vec::new())), ])) } -fn describe_tool_flags(schema: &Value) -> Vec { +fn describe_binding_flags(schema: &Value) -> Vec { let properties = schema .get("properties") .and_then(Value::as_object) @@ -3381,7 +3444,7 @@ fn describe_tool_flags(schema: &Value) -> Vec { ), ( "type", - Value::String(describe_tool_flag_type(&field_schema)), + Value::String(describe_binding_flag_type(&field_schema)), ), ("required", Value::Bool(required.contains(&field_name))), ]) @@ -3389,7 +3452,7 @@ fn describe_tool_flags(schema: &Value) -> Vec { .collect() } -fn describe_tool_flag_type(schema: &Value) -> String { +fn describe_binding_flag_type(schema: &Value) -> String { match json_schema_type(schema) { Some("array") => { let item_type = schema @@ -3410,7 +3473,10 @@ fn describe_tool_flag_type(schema: &Value) -> String { } } -fn tool_permission_mode(permissions: Option<&Permissions>, callback_key: &str) -> PermissionMode { +fn binding_permission_mode( + permissions: Option<&Permissions>, + callback_key: &str, +) -> PermissionMode { let Some(permissions) = permissions else { return PermissionMode::Allow; }; @@ -3508,19 +3574,19 @@ fn permission_pattern_matches(pattern: &str, value: &str) -> bool { pattern_index == pattern_bytes.len() } -fn toolkit_names(tool_kits: &[ToolKit]) -> String { - tool_kits +fn binding_group_names(binding_groups: &[BindingGroup]) -> String { + binding_groups .iter() - .map(|toolkit| toolkit.name.clone()) + .map(|binding_group| binding_group.name.clone()) .collect::>() .join(", ") } -fn tool_names(toolkit: &ToolKit) -> String { - toolkit - .tools +fn binding_names(binding_group: &BindingGroup) -> String { + binding_group + .bindings .iter() - .map(|tool| tool.name.clone()) + .map(|binding| binding.name.clone()) .collect::>() .join(", ") } @@ -3870,10 +3936,10 @@ mod tests { JoinHandle, }; use crate::config::{ - AgentOsConfig, AgentOsLimits, FsPermissionRule, FsPermissions, HttpLimits, JsRuntimeLimits, - MountPlugin, PatternPermissions, PermissionMode, Permissions, ResourceLimits, - RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode, RootLowerInput, - RulePermissions, ToolLimits, + AgentOsConfig, AgentOsLimits, BindingLimits, FsPermissionRule, FsPermissions, HttpLimits, + JsRuntimeLimits, MountPlugin, PatternPermissions, PermissionMode, Permissions, + ResourceLimits, RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode, + RootLowerInput, RulePermissions, }; use crate::fs::{ DirEntryType, FilesystemEntry, FilesystemEntryEncoding, FilesystemSnapshotEntries, @@ -4151,9 +4217,9 @@ mod tests { http: Some(HttpLimits { max_fetch_response_bytes: Some(1024), }), - tools: Some(ToolLimits { - default_tool_timeout_ms: Some(500), - max_registered_tools_per_vm: Some(12), + bindings: Some(BindingLimits { + default_binding_timeout_ms: Some(500), + max_registered_bindings_per_vm: Some(12), ..Default::default() }), js_runtime: Some(JsRuntimeLimits { @@ -4178,14 +4244,14 @@ mod tests { limits .tools .as_ref() - .expect("tool limits") + .expect("binding limits") .default_tool_timeout_ms, Some(500) ); assert_eq!( limits .tools - .expect("tool limits") + .expect("binding limits") .max_registered_tools_per_vm, Some(12) ); diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index b7b049de0..69e8f0a26 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -43,8 +43,8 @@ pub struct AgentOsConfig { pub additional_instructions: Option, /// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`]. pub schedule_driver: Option>, - /// Tool kits to register. - pub tool_kits: Vec, + /// Binding groups to register. + pub binding_groups: Vec, /// Rust-only sidecar callback handler for `js_bridge`-style plugin requests. pub sidecar_js_bridge_callback: Option, /// Permission policy. Default: allow-all. @@ -116,8 +116,8 @@ impl AgentOsConfigBuilder { self } - pub fn tool_kits(mut self, tool_kits: Vec) -> Self { - self.config.tool_kits = tool_kits; + pub fn binding_groups(mut self, binding_groups: Vec) -> Self { + self.config.binding_groups = binding_groups; self } @@ -162,7 +162,7 @@ pub enum SoftwareKind { WasmCommands, /// An agent SDK/adapter package. Not mounted as a command directory. Agent, - /// A host-tool package. Not mounted as a command directory. + /// A host-binding package. Not mounted as a command directory. Tool, } @@ -185,10 +185,10 @@ pub struct PackageRef { pub dir: String, } -/// A host-side tool execute callback. Receives the validated JSON input, returns a JSON result or an +/// A host-side binding execute callback. Receives the validated JSON input, returns a JSON result or an /// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the /// sidecar host-callback channel. -pub type ToolCallback = Arc< +pub type BindingCallback = Arc< dyn Fn( serde_json::Value, ) -> futures::future::BoxFuture<'static, Result> @@ -219,26 +219,26 @@ pub type SidecarJsBridgeCallback = Arc< + Sync, >; -/// A single host tool within a [`ToolKit`]. +/// A single host binding within a [`BindingGroup`]. #[derive(Clone)] -pub struct HostTool { +pub struct Binding { pub name: String, pub description: String, - /// JSON Schema for the tool input (forwarded to the sidecar `register_host_callbacks` definition). + /// JSON Schema for the binding input (forwarded to the sidecar `register_host_callbacks` definition). pub input_schema: serde_json::Value, pub timeout_ms: Option, - /// Host-side implementation, invoked when the guest calls `:`. - pub execute: ToolCallback, + /// Host-side implementation, invoked when the guest calls `:`. + pub execute: BindingCallback, } -/// A registered tool kit (in-process; tool implementations stay host-side). Tools are exposed to the -/// guest as `:` and dispatched back to [`HostTool::execute`] via the sidecar +/// A registered binding group (in-process; binding implementations stay host-side). Bindings are exposed to the +/// guest as `:` and dispatched back to [`Binding::execute`] via the sidecar /// host-callback channel. #[derive(Clone)] -pub struct ToolKit { +pub struct BindingGroup { pub name: String, pub description: String, - pub tools: Vec, + pub bindings: Vec, } // --------------------------------------------------------------------------- @@ -254,7 +254,7 @@ pub struct AgentOsLimits { #[serde(default, skip_serializing_if = "Option::is_none")] pub http: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub bindings: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub plugins: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -390,55 +390,55 @@ pub struct HttpLimits { } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolLimits { +pub struct BindingLimits { #[serde( default, - rename = "defaultToolTimeoutMs", + rename = "defaultBindingTimeoutMs", skip_serializing_if = "Option::is_none" )] - pub default_tool_timeout_ms: Option, + pub default_binding_timeout_ms: Option, #[serde( default, - rename = "maxToolTimeoutMs", + rename = "maxBindingTimeoutMs", skip_serializing_if = "Option::is_none" )] - pub max_tool_timeout_ms: Option, + pub max_binding_timeout_ms: Option, #[serde( default, - rename = "maxRegisteredToolkits", + rename = "maxRegisteredBindingGroups", skip_serializing_if = "Option::is_none" )] - pub max_registered_toolkits: Option, + pub max_registered_binding_groups: Option, #[serde( default, - rename = "maxRegisteredToolsPerVm", + rename = "maxRegisteredBindingsPerVm", skip_serializing_if = "Option::is_none" )] - pub max_registered_tools_per_vm: Option, + pub max_registered_bindings_per_vm: Option, #[serde( default, - rename = "maxToolsPerToolkit", + rename = "maxBindingsPerGroup", skip_serializing_if = "Option::is_none" )] - pub max_tools_per_toolkit: Option, + pub max_bindings_per_group: Option, #[serde( default, - rename = "maxToolSchemaBytes", + rename = "maxBindingSchemaBytes", skip_serializing_if = "Option::is_none" )] - pub max_tool_schema_bytes: Option, + pub max_binding_schema_bytes: Option, #[serde( default, - rename = "maxToolExamplesPerTool", + rename = "maxBindingExamplesPerBinding", skip_serializing_if = "Option::is_none" )] - pub max_tool_examples_per_tool: Option, + pub max_binding_examples_per_binding: Option, #[serde( default, - rename = "maxToolExampleInputBytes", + rename = "maxBindingExampleInputBytes", skip_serializing_if = "Option::is_none" )] - pub max_tool_example_input_bytes: Option, + pub max_binding_example_input_bytes: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 273a00b5a..d9873412a 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -66,14 +66,14 @@ pub use sidecar::{ pub use stream::{ByteStream, Subscription}; pub use config::{ - AcpLimits, AgentOsConfig, AgentOsConfigBuilder, AgentOsLimits, AgentOsSidecarConfig, - FsPermissionRule, FsPermissions, HostTool, HttpLimits, JsRuntimeLimits, MountConfig, - MountPlugin, OverlayMountConfig, PatternPermissionRule, PatternPermissions, PermissionMode, - PackageRef, Permissions, PluginLimits, PythonLimits, ResourceLimits, RootFilesystemConfig, - RootFilesystemKind, RootFilesystemMode, RootLowerInput, RulePermissions, ScheduleCallback, - ScheduleDriver, ScheduleEntry, ScheduleHandle, SidecarJsBridgeCall, SidecarJsBridgeCallback, - SoftwareInput, SoftwareKind, TimerScheduleDriver, ToolCallback, ToolKit, ToolLimits, - WasmLimits, + AcpLimits, AgentOsConfig, AgentOsConfigBuilder, AgentOsLimits, AgentOsSidecarConfig, Binding, + BindingCallback, BindingGroup, BindingLimits, FsPermissionRule, FsPermissions, HttpLimits, + JsRuntimeLimits, MountConfig, MountPlugin, OverlayMountConfig, PackageRef, + PatternPermissionRule, PatternPermissions, PermissionMode, Permissions, PluginLimits, + PythonLimits, ResourceLimits, RootFilesystemConfig, RootFilesystemKind, RootFilesystemMode, + RootLowerInput, RulePermissions, ScheduleCallback, ScheduleDriver, ScheduleEntry, + ScheduleHandle, SidecarJsBridgeCall, SidecarJsBridgeCallback, SoftwareInput, SoftwareKind, + TimerScheduleDriver, WasmLimits, }; pub use process::{ diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 02b9a89e1..3a3dc4c9d 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -26,7 +26,7 @@ use agentos_protocol::ACP_EXTENSION_NAMESPACE; use secure_exec_client::wire; use crate::agent_os::{AgentOs, SessionEntry}; -use crate::config::{AgentOsConfig, MountConfig, ToolKit}; +use crate::config::{AgentOsConfig, BindingGroup, MountConfig}; use crate::error::ClientError; use crate::json_rpc::{JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse}; use crate::stream::Subscription; @@ -925,25 +925,25 @@ fn combine_instructions(additional: Option<&str>, tool_reference: &str) -> Optio } } -fn build_host_tool_reference(tool_kits: &[ToolKit]) -> String { - if tool_kits.is_empty() { +fn build_host_binding_reference(binding_groups: &[BindingGroup]) -> String { + if binding_groups.is_empty() { return String::new(); } let mut lines = vec![ - String::from("## Available Host Tools"), + String::from("## Available Host Bindings"), String::new(), - String::from("Run `agentos list-tools` to see all available tools."), + String::from("Run `agentos list-bindings` to see all available bindings."), String::new(), ]; - for kit in tool_kits { + for kit in binding_groups { lines.push(format!("### {}", kit.name)); lines.push(String::new()); lines.push(kit.description.clone()); lines.push(String::new()); - for tool in &kit.tools { - let signature = build_tool_flag_signature(&tool.input_schema); + for binding in &kit.bindings { + let signature = build_binding_flag_signature(&binding.input_schema); let suffix = if signature.is_empty() { String::new() } else { @@ -951,12 +951,12 @@ fn build_host_tool_reference(tool_kits: &[ToolKit]) -> String { }; lines.push(format!( "- `agentos-{} {}{}` — {}", - kit.name, tool.name, suffix, tool.description + kit.name, binding.name, suffix, binding.description )); } lines.push(String::new()); lines.push(format!( - "Run `agentos-{} --help` for details.", + "Run `agentos-{} --help` for details.", kit.name )); lines.push(String::new()); @@ -965,8 +965,8 @@ fn build_host_tool_reference(tool_kits: &[ToolKit]) -> String { lines.join("\n") } -fn build_tool_flag_signature(schema: &Value) -> String { - describe_tool_flags(schema) +fn build_binding_flag_signature(schema: &Value) -> String { + describe_binding_flags(schema) .into_iter() .map(|flag| { if flag.required { @@ -979,13 +979,13 @@ fn build_tool_flag_signature(schema: &Value) -> String { .join(" ") } -struct ToolFlagDescription { +struct BindingFlagDescription { name: String, value_type: String, required: bool, } -fn describe_tool_flags(schema: &Value) -> Vec { +fn describe_binding_flags(schema: &Value) -> Vec { let properties = schema .get("properties") .and_then(Value::as_object) @@ -1005,15 +1005,15 @@ fn describe_tool_flags(schema: &Value) -> Vec { properties .into_iter() - .map(|(field_name, field_schema)| ToolFlagDescription { + .map(|(field_name, field_schema)| BindingFlagDescription { name: format!("--{}", camel_to_kebab(&field_name)), - value_type: describe_tool_flag_type(&field_schema), + value_type: describe_binding_flag_type(&field_schema), required: required.contains(&field_name), }) .collect() } -fn describe_tool_flag_type(schema: &Value) -> String { +fn describe_binding_flag_type(schema: &Value) -> String { match json_schema_type(schema) { Some("array") => { let item_type = schema @@ -1308,7 +1308,7 @@ impl AgentOs { /// Create an ACP session. Resolves the agent config, merges env (user wins), creates the session /// via the sidecar (`runtime: java_script`, protocol v1, default client caps), and hydrates - /// state. Agent OS owns dynamic tool-reference instructions and forwards them as additional + /// state. Agent OS owns dynamic binding-reference instructions and forwards them as additional /// instructions; the sidecar owns final base-prompt assembly and agent-specific injection. On /// hydration failure the session is removed and the error rethrown. Returns the session id only. pub async fn create_session( @@ -1354,7 +1354,7 @@ impl AgentOs { "fs": { "readTextFile": true, "writeTextFile": true }, "terminal": true, }); - let tool_reference = build_host_tool_reference(&self.config().tool_kits); + let tool_reference = build_host_binding_reference(&self.config().binding_groups); let additional_instructions = combine_instructions(options.additional_instructions.as_deref(), &tool_reference); diff --git a/crates/client/tests/os_instructions_e2e.rs b/crates/client/tests/os_instructions_e2e.rs index 3f96faf61..e89361c99 100644 --- a/crates/client/tests/os_instructions_e2e.rs +++ b/crates/client/tests/os_instructions_e2e.rs @@ -1,7 +1,7 @@ //! End-to-end coverage for sidecar-owned system-prompt injection at `create_session`. //! //! The base prompt is no longer baked into a guest file (`/etc/agentos/instructions.md` is gone); -//! the Agent OS client passes create-time additions and generated tool docs to the wrapper sidecar, +//! the Agent OS client passes create-time additions and generated binding docs to the wrapper sidecar, //! which assembles them with the base prompt and injects the result into the launched adapter's argv //! (`--append-system-prompt` for `pi`). This test resolves a tiny mock ACP adapter through the real //! module-access path, launches a `pi` session, and asserts the adapter actually observed the @@ -14,8 +14,8 @@ use std::path::Path; use std::sync::Arc; use agentos_client::config::{ - AgentOsConfig, AgentOsSidecarConfig, FsPermissions, HostTool, PatternPermissions, - PermissionMode, Permissions, ToolKit, + AgentOsConfig, AgentOsSidecarConfig, Binding, BindingGroup, FsPermissions, PatternPermissions, + PermissionMode, Permissions, }; use agentos_client::{AgentOs, CreateSessionOptions}; use serde_json::json; @@ -72,7 +72,7 @@ fn allow_all_permissions() -> Permissions { child_process: Some(PatternPermissions::Mode(PermissionMode::Allow)), process: Some(PatternPermissions::Mode(PermissionMode::Allow)), env: Some(PatternPermissions::Mode(PermissionMode::Allow)), - tool: Some(PatternPermissions::Mode(PermissionMode::Allow)), + binding: Some(PatternPermissions::Mode(PermissionMode::Allow)), } } @@ -104,13 +104,13 @@ async fn launch_pi_session_and_read_argv(options: CreateSessionOptions) -> Vec, + binding_groups: Vec, ) -> Vec { let module_access_dir = std::env::temp_dir().join(format!("agentos-client-os-instructions-{}", Uuid::new_v4())); write_mock_pi_adapter(&module_access_dir); - let argv = run_session(&module_access_dir, options, tool_kits).await; + let argv = run_session(&module_access_dir, options, binding_groups).await; std::fs::remove_dir_all(&module_access_dir).ok(); argv @@ -119,14 +119,14 @@ async fn launch_pi_session_with_tools_and_read_argv( async fn run_session( module_access_dir: &Path, options: CreateSessionOptions, - tool_kits: Vec, + binding_groups: Vec, ) -> Vec { let os = AgentOs::create(AgentOsConfig { module_access_cwd: Some(module_access_dir.to_string_lossy().into_owned()), sidecar: Some(AgentOsSidecarConfig::Shared { pool: Some(format!("os-instructions-{}", Uuid::new_v4())), }), - tool_kits, + binding_groups, permissions: Some(allow_all_permissions()), ..Default::default() }) @@ -201,10 +201,10 @@ async fn create_session_injects_host_tool_reference_from_client_config() { let argv = launch_pi_session_with_tools_and_read_argv( CreateSessionOptions::default(), - vec![ToolKit { + vec![BindingGroup { name: "weather".to_string(), - description: "Weather lookup tools.".to_string(), - tools: vec![HostTool { + description: "Weather lookup bindings.".to_string(), + bindings: vec![Binding { name: "forecast".to_string(), description: "Get a forecast.".to_string(), input_schema: json!({ @@ -223,12 +223,12 @@ async fn create_session_injects_host_tool_reference_from_client_config() { let prompt = injected_prompt(&argv); assert!( - prompt.contains("## Available Host Tools"), - "client-generated tool reference is injected: {prompt:?}" + prompt.contains("## Available Host Bindings"), + "client-generated binding reference is injected: {prompt:?}" ); assert!( prompt.contains("`agentos-weather forecast --zip-code `"), - "tool reference includes CLI command and schema-derived flags: {prompt:?}" + "binding reference includes CLI command and schema-derived flags: {prompt:?}" ); } diff --git a/docs-internal/kernel-runtime-subsystem-map.md b/docs-internal/kernel-runtime-subsystem-map.md index 156d9d194..1c83de1a8 100644 --- a/docs-internal/kernel-runtime-subsystem-map.md +++ b/docs-internal/kernel-runtime-subsystem-map.md @@ -399,7 +399,7 @@ What lives here: ### Native sidecar tool virtualization -This is the subsystem that makes registered toolkits show up as VM commands. +This is the subsystem that makes registered binding groups show up as VM commands. Relevant files: - `crates/sidecar/src/tools.rs` @@ -408,9 +408,9 @@ Relevant files: What lives here: - Toolkit registration. -- Prompt/reference markdown generation for toolkits. +- Prompt/reference markdown generation for binding groups. - CLI-style flag parsing from JSON Schema. -- Resolution of `agentos`, toolkit commands, and tool invocations into sidecar-dispatched virtual processes. +- Resolution of `agentos`, binding group commands, and tool invocations into sidecar-dispatched virtual processes. ### Native sidecar process/runtime dispatch diff --git a/examples/agent-to-agent/README.md b/examples/agent-to-agent/README.md index d3a2dd96f..a9c9b4c99 100644 --- a/examples/agent-to-agent/README.md +++ b/examples/agent-to-agent/README.md @@ -9,7 +9,7 @@ Run two agents in separate isolated VMs and let one delegate to the other. The w ## How it works -Both agents are independent `agentOS` VMs registered under one `setup`. The writer is given a `review` binding: a host-side tool the agent can invoke by name. When the writer runs `agentos-review submit --path ...`, the binding's `execute` runs on the host, where it reads the file out of the writer's VM, copies it into the reviewer's VM, opens a reviewer session, and prompts the reviewer to review the code. The review text is returned to the writer as the binding's result. The two VMs never touch directly — the host bridge is the only path between them. +Both agents are independent `agentOS` VMs registered under one `setup`. The writer is given a `review` binding: a host-side function the agent can invoke by name. When the writer runs `agentos-review submit --path ...`, the binding's `execute` runs on the host, where it reads the file out of the writer's VM, copies it into the reviewer's VM, opens a reviewer session, and prompts the reviewer to review the code. The review text is returned to the writer as the binding's result. The two VMs never touch directly — the host bridge is the only path between them. ## Run it diff --git a/examples/agent-to-agent/client.ts b/examples/agent-to-agent/client.ts index 8b165cf65..aea8ba11b 100644 --- a/examples/agent-to-agent/client.ts +++ b/examples/agent-to-agent/client.ts @@ -8,7 +8,7 @@ const sessionId = await writerAgent.createSession("claude", { env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, }); -// The writer calls the `review` toolkit, which bridges to the reviewer VM. +// The writer calls the `review` binding, which bridges to the reviewer VM. await writerAgent.sendPrompt( sessionId, "Write a small REST API, then send it to the review agent for review.", diff --git a/examples/agent-to-agent/server.ts b/examples/agent-to-agent/server.ts index 6f095c97c..4bb272753 100644 --- a/examples/agent-to-agent/server.ts +++ b/examples/agent-to-agent/server.ts @@ -28,14 +28,14 @@ async function reviewCode(code: string): Promise { return result.text; } -// The writer agent gets a `review` toolkit. When the writer runs +// The writer agent gets a `review` binding group. When the writer runs // `agentos-review submit`, the bridge above executes on the host. const writer = agentOS({ - toolKits: [ + bindings: [ { name: "review", description: "Send code to the reviewer agent and get back a review.", - tools: { + bindings: { submit: { description: "Submit the full contents of a file to the reviewer agent for review. Returns the reviewer's feedback as text.", diff --git a/examples/bindings/README.md b/examples/bindings/README.md index fec6e8671..710f092dc 100644 --- a/examples/bindings/README.md +++ b/examples/bindings/README.md @@ -5,11 +5,11 @@ category: "Reference" order: 3 --- -Give an agent access to your own host code—API calls, database lookups, internal services—without writing a tool from scratch. Reach for bindings when the agent needs to call back into your application and you want type-safe inputs plus an auto-generated CLI surface inside the VM. +Give an agent access to your own host code—API calls, database lookups, internal services—without writing a binding from scratch. Reach for bindings when the agent needs to call back into your application and you want type-safe inputs plus an auto-generated CLI surface inside the VM. ## How it works -A binding group bundles a `name`, a `description`, and a map of named tools. Each tool declares a Zod `inputSchema`, an `execute` handler that runs on the host, and optional `examples`. You pass the groups to `agentOS({ toolKits: [...] })`, and Agent OS exposes every group to the agent as a CLI command at `/usr/local/bin/agentos-{name}` inside the VM. When the agent invokes the command, the Zod schema validates the arguments and the handler executes host-side, returning the result back to the guest. The client side stays thin: create a session and send a prompt, and the agent decides when to call the binding. +A binding group bundles a `name`, a `description`, and a map of named bindings. Each binding declares a Zod `inputSchema`, an `execute` handler that runs on the host, and optional `examples`. You pass the groups to `agentOS({ bindings: [...] })`, and Agent OS exposes every group to the agent as a CLI command at `/usr/local/bin/agentos-{name}` inside the VM. When the agent invokes the command, the Zod schema validates the arguments and the handler executes host-side, returning the result back to the guest. The client side stays thin: create a session and send a prompt, and the agent decides when to call the binding. ## Run it diff --git a/examples/bindings/server.ts b/examples/bindings/server.ts index 2b7ce4c91..961c81aa5 100644 --- a/examples/bindings/server.ts +++ b/examples/bindings/server.ts @@ -1,13 +1,13 @@ import { agentOS, setup } from "@rivet-dev/agentos"; import { z } from "zod"; -// Define a group of bindings (host functions). Each tool has a Zod input +// Define a group of bindings (host functions). Each binding has a Zod input // schema and an `execute` handler that runs on the host. The group is exposed to // the agent as a CLI command at /usr/local/bin/agentos-{name} inside the VM. const weatherBindings = { name: "weather", description: "Weather data bindings", - tools: { + bindings: { forecast: { description: "Get the weather forecast for a city", inputSchema: z.object({ @@ -28,7 +28,7 @@ const weatherBindings = { }; const vm = agentOS({ - toolKits: [weatherBindings], + bindings: [weatherBindings], }); export const registry = setup({ use: { vm } }); diff --git a/examples/crash-course/agent-to-agent-server.ts b/examples/crash-course/agent-to-agent-server.ts index 5365fa342..b85a796e4 100644 --- a/examples/crash-course/agent-to-agent-server.ts +++ b/examples/crash-course/agent-to-agent-server.ts @@ -6,15 +6,15 @@ import pi from "@agentos-software/pi"; // The reviewer is its own isolated agent VM. const reviewer = agentOS({ software: [pi] }); -// The coder gets a `review` toolkit it can call itself: it copies a file from the +// The coder gets a `review` binding it can call itself: it copies a file from the // coder's VM into the reviewer's VM and asks the reviewer to review it. const coder = agentOS({ software: [pi], - toolKits: [ + bindings: [ { name: "review", description: "Send a file to the reviewer agent and get back a review.", - tools: { + bindings: { submit: { description: "Submit a file path for review by the reviewer agent.", inputSchema: z.object({ path: z.string() }), diff --git a/examples/crash-course/sandbox.ts b/examples/crash-course/sandbox.ts index d7767cd72..2e52ebb76 100644 --- a/examples/crash-course/sandbox.ts +++ b/examples/crash-course/sandbox.ts @@ -1,18 +1,24 @@ -import { agentOS, setup } from "@rivet-dev/agentos"; -import { createSandboxFs, createSandboxBindings } from "@rivet-dev/agentos-sandbox"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import { + createSandboxBindings, + createSandboxFs, +} from "@rivet-dev/agentos-sandbox"; import { SandboxAgent } from "sandbox-agent"; import { docker } from "sandbox-agent/docker"; const sandbox = await SandboxAgent.start({ sandbox: docker() }); -const vm = agentOS({ - // Toolkits let the agent control the sandbox - toolKits: [createSandboxBindings({ client: sandbox })], - // Mounts let the agent read the sandbox filesystem (optional) +const vm = await AgentOs.create({ + // Bindings let the agent control the sandbox. + bindings: [createSandboxBindings({ client: sandbox })], + // Mounts let the agent read the sandbox filesystem. mounts: [ - { path: "/home/agentos/sandbox", plugin: createSandboxFs({ client: sandbox }) }, + { + path: "/home/agentos/sandbox", + plugin: createSandboxFs({ client: sandbox }), + }, ], }); -export const registry = setup({ use: { vm } }); -registry.start(); +await vm.dispose(); +await sandbox.dispose(); diff --git a/examples/quickstart/bindings/README.md b/examples/quickstart/bindings/README.md new file mode 100644 index 000000000..f1451bdcf --- /dev/null +++ b/examples/quickstart/bindings/README.md @@ -0,0 +1,25 @@ +--- +title: "Bindings" +description: "Define host-side bindings callable from inside the VM as CLI commands." +category: "Quickstart" +order: 9 +--- + +Expose host-side functions to code running inside the VM. Reach for this when guest code needs to call back out to capabilities you implement on the host — weather lookups, calculators, database access — without granting it direct host access. + +## How it works + +You declare binding groups with `bindingGroup`, where each `binding` pairs a Zod `inputSchema` with an `execute` function that runs on the host. Pass the groups to `AgentOs.create({ bindings })` and Agent OS installs CLI commands inside the VM. This example wires up `weather` and `calc`, then invokes each through `agentos-weather` and `agentos-calc`. + +## Run it + +```bash +npm install +npx tsx index.ts +``` + +Prints the weather and calculator results returned from the host. + +## Source + +View the source on GitHub: https://github.com/rivet-dev/agent-os/tree/main/examples/quickstart/bindings diff --git a/examples/quickstart/bindings/index.ts b/examples/quickstart/bindings/index.ts new file mode 100644 index 000000000..2bf0c8e89 --- /dev/null +++ b/examples/quickstart/bindings/index.ts @@ -0,0 +1,61 @@ +// Bindings: define functions that execute on the host and are callable +// from inside the VM as CLI commands. + +import { AgentOs, binding, bindingGroup } from "@rivet-dev/agentos-core"; +import { z } from "zod"; + +const weatherBindings = bindingGroup({ + name: "weather", + description: "Look up weather information for cities.", + bindings: { + get: binding({ + description: "Get the current weather for a city.", + inputSchema: z.object({ + city: z.string().describe("City name (e.g. 'London')."), + }), + execute: async (input) => { + const { city } = input; + return { + city, + temperature: 18, + conditions: "partly cloudy", + humidity: 65, + }; + }, + examples: [ + { description: "Get London weather", input: { city: "London" } }, + ], + }), + }, +}); + +const calcBindings = bindingGroup({ + name: "calc", + description: "Simple calculator operations.", + bindings: { + add: binding({ + description: "Add two numbers.", + inputSchema: z.object({ a: z.number(), b: z.number() }), + execute: (input) => ({ result: input.a + input.b }), + }), + }, +}); + +const vm = await AgentOs.create({ + bindings: [weatherBindings, calcBindings], + permissions: { + fs: "allow", + network: "allow", + childProcess: "allow", + env: "allow", + binding: "allow", + }, +}); + +const weather = await vm.exec("agentos-weather get --city London"); +console.log("Weather:", JSON.stringify(weather)); + +const sum = await vm.exec("agentos-calc add --a 10 --b 32"); +console.log("Sum:", JSON.stringify(sum)); + +await vm.dispose(); diff --git a/examples/quickstart/tools/package.json b/examples/quickstart/bindings/package.json similarity index 92% rename from examples/quickstart/tools/package.json rename to examples/quickstart/bindings/package.json index 5982f0c93..d329d4b4a 100644 --- a/examples/quickstart/tools/package.json +++ b/examples/quickstart/bindings/package.json @@ -1,5 +1,5 @@ { - "name": "@rivet-dev/agentos-example-quickstart-tools", + "name": "@rivet-dev/agentos-example-quickstart-bindings", "version": "0.1.0", "private": true, "type": "module", diff --git a/examples/quickstart/tools/tsconfig.json b/examples/quickstart/bindings/tsconfig.json similarity index 100% rename from examples/quickstart/tools/tsconfig.json rename to examples/quickstart/bindings/tsconfig.json diff --git a/examples/quickstart/package.json b/examples/quickstart/package.json index 9e1ee8b1a..f6b45a621 100644 --- a/examples/quickstart/package.json +++ b/examples/quickstart/package.json @@ -13,7 +13,7 @@ "processes": "node --import tsx src/processes.ts", "network": "node --import tsx src/network.ts", "cron": "node --import tsx src/cron.ts", - "tools": "node --import tsx src/tools.ts", + "bindings": "node --import tsx bindings/index.ts", "agent-session": "node --import tsx src/agent-session.ts", "sandbox": "node --import tsx src/sandbox.ts", "nodejs": "node --import tsx src/nodejs.ts", diff --git a/examples/quickstart/sandbox/README.md b/examples/quickstart/sandbox/README.md index 205f241c2..7bd13e11a 100644 --- a/examples/quickstart/sandbox/README.md +++ b/examples/quickstart/sandbox/README.md @@ -1,6 +1,6 @@ --- title: "Sandbox" -description: "Mount a Docker sandbox filesystem and run commands through the sandbox toolkit." +description: "Mount a Docker sandbox filesystem and run commands through sandbox bindings." category: "Quickstart" order: 11 --- @@ -9,7 +9,7 @@ Back a VM with a Docker-backed sandbox so guest reads, writes, and commands run ## How it works -A `SandboxAgent` starts a Docker container via `sandbox-agent`. Two pieces wire it into the VM: `createSandboxFs` mounts the container's filesystem at `/sandbox`, and `createSandboxToolkit` registers a `sandbox` toolkit for running commands. Files written under `/sandbox` land in the container, and tools like `run-command` and `list-processes` execute against it over the VM's tools RPC port. Set `SKIP_DOCKER=1` to no-op the example where Docker is unavailable. +The quickstart starts one Docker container for one VM. Two pieces wire it into the VM: `createSandboxFs` mounts the container's filesystem at `/sandbox`, and `createSandboxBindings` registers sandbox bindings for running commands. Files written under `/sandbox` land in the container, and commands like `run-command` and `list-processes` execute through the `agentos-sandbox` CLI. Set `SKIP_DOCKER=1` to no-op the example where Docker is unavailable. ## Run it @@ -18,7 +18,7 @@ npm install npx tsx index.ts ``` -You should see a file read back from the sandbox mount, the tools RPC port, and the output of an `echo` command plus a process listing from inside the Docker sandbox. +You should see a file read back from the sandbox mount, the output of an `echo` command, and a process listing from inside the Docker sandbox. ## Source diff --git a/examples/quickstart/sandbox/index.ts b/examples/quickstart/sandbox/index.ts index 3978b42b3..788a9a837 100644 --- a/examples/quickstart/sandbox/index.ts +++ b/examples/quickstart/sandbox/index.ts @@ -1,13 +1,15 @@ // Sandbox extension: mount a Docker sandbox filesystem and run commands. // // Requires Docker. Starts a sandbox-agent container, mounts its filesystem -// at /sandbox, and registers the sandbox toolkit for running commands. +// at /sandbox, and registers sandbox bindings for running commands. import { AgentOs } from "@rivet-dev/agentos-core"; import { + createSandboxBindings, createSandboxFs, - createSandboxToolkit, } from "@rivet-dev/agentos-sandbox"; +import { SandboxAgent } from "sandbox-agent"; +import { docker } from "sandbox-agent/docker"; const SANDBOX_QUICKSTART_PERMISSIONS = { fs: "allow", @@ -18,79 +20,15 @@ const SANDBOX_QUICKSTART_PERMISSIONS = { } as const; const skipDocker = process.env.SKIP_DOCKER === "1"; -async function readToolsPort(vm: AgentOs): Promise { - let stdout = ""; - let stderr = ""; - await vm.writeFile( - "/tmp/read-tools-port.cjs", - 'process.stdout.write(process.env.AGENTOS_TOOLS_PORT||"")', - ); - const proc = vm.spawn("node", ["/tmp/read-tools-port.cjs"], { - onStdout: (data) => { - stdout += new TextDecoder().decode(data); - }, - onStderr: (data) => { - stderr += new TextDecoder().decode(data); - }, - }); - const exitCode = await vm.waitProcess(proc.pid); - if (exitCode !== 0) { - throw new Error(`Failed to read AGENTOS_TOOLS_PORT: ${stderr.trim()}`); - } - const port = stdout.trim(); - if (!port) { - throw new Error("AGENTOS_TOOLS_PORT is not set inside the VM"); - } - return port; -} - -async function callTool( - vm: AgentOs, - port: string, - toolkit: string, - tool: string, - input: Record, -): Promise { - const outFile = `/tmp/${toolkit}-${tool}-out.json`; - let stderr = ""; - const source = [ - 'import{writeFileSync as w}from"node:fs";', - `const r=await fetch("http://127.0.0.1:${port}/call",{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify( - JSON.stringify({ toolkit, tool, input }), - )}});`, - `w(${JSON.stringify(outFile)},await r.text());`, - ].join(""); - await vm.writeFile("/tmp/tool-call.mjs", source); - const proc = vm.spawn("node", ["/tmp/tool-call.mjs"], { - onStderr: (data) => { - stderr += new TextDecoder().decode(data); - }, - }); - const exitCode = await vm.waitProcess(proc.pid); - if (exitCode !== 0) { - throw new Error( - `Tool call process exited with code ${exitCode}: ${stderr.trim()}`, - ); - } - return JSON.parse(new TextDecoder().decode(await vm.readFile(outFile))); -} - if (skipDocker) { console.log("Skipping sandbox quickstart because SKIP_DOCKER=1."); process.exit(0); } -const [{ SandboxAgent }, { docker }] = await Promise.all([ - import("sandbox-agent"), - import("sandbox-agent/docker"), -]); - -// Start a Docker-backed sandbox. const sandbox = await SandboxAgent.start({ sandbox: docker(), }); -// Mount the sandbox filesystem at /sandbox and register the toolkit. const vm = await AgentOs.create({ permissions: SANDBOX_QUICKSTART_PERMISSIONS, mounts: [ @@ -99,25 +37,23 @@ const vm = await AgentOs.create({ plugin: createSandboxFs({ client: sandbox }), }, ], - toolKits: [createSandboxToolkit({ client: sandbox })], + bindings: [createSandboxBindings({ client: sandbox })], }); -// Write and read a file through the mounted sandbox filesystem. -await vm.writeFile("/sandbox/hello.txt", "Hello from agentOS!"); -const content = await vm.readFile("/sandbox/hello.txt"); -console.log("Read from sandbox mount:", new TextDecoder().decode(content)); +try { + // Write and read a file through the mounted sandbox filesystem. + await vm.writeFile("/sandbox/hello.txt", "Hello from agentOS!"); + const content = await vm.readFile("/sandbox/hello.txt"); + console.log("Read from sandbox mount:", new TextDecoder().decode(content)); -const port = await readToolsPort(vm); -console.log("Tools RPC port:", port); - -const runCommandResult = await callTool(vm, port, "sandbox", "run-command", { - command: "echo", - args: ["hello from Docker sandbox"], -}); -console.log("Sandbox command:", JSON.stringify(runCommandResult)); - -const processList = await callTool(vm, port, "sandbox", "list-processes", {}); -console.log("Sandbox processes:", JSON.stringify(processList)); + const runCommandResult = await vm.exec( + "agentos-sandbox run-command --command echo --args 'hello from Docker sandbox'", + ); + console.log("Sandbox command:", JSON.stringify(runCommandResult)); -await vm.dispose(); -await sandbox.dispose(); + const processList = await vm.exec("agentos-sandbox list-processes"); + console.log("Sandbox processes:", JSON.stringify(processList)); +} finally { + await vm.dispose(); + await sandbox.dispose(); +} diff --git a/examples/quickstart/tools/README.md b/examples/quickstart/tools/README.md deleted file mode 100644 index 49ef9c3bb..000000000 --- a/examples/quickstart/tools/README.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: "Tools" -description: "Define host-side tool kits callable from inside the VM over the tools RPC server." -category: "Quickstart" -order: 9 ---- - -Expose host-side functions to code running inside the VM. Reach for this when guest code needs to call back out to capabilities you implement on the host — weather lookups, calculators, database access — without granting it direct host access. - -## How it works - -You declare tool kits with `toolKit`, where each `hostTool` pairs a Zod `inputSchema` with an `execute` function that runs on the host. Pass the kits to `AgentOs.create` and the runtime stands up a tools RPC server inside the VM, advertised through the `AGENTOS_TOOLS_PORT` environment variable. Guest Node scripts read that port and `POST` to `/call` with a `{ toolkit, tool, input }` body; the host validates the input, runs `execute`, and returns the result as JSON. This example wires up `weather` and `calc` kits, then invokes each from inside the VM. - -## Run it - -```bash -npm install -npx tsx index.ts -``` - -Prints the tools RPC port, then the weather and calculator results returned from the host. - -## Source - -View the source on GitHub: https://github.com/rivet-dev/agent-os/tree/main/examples/quickstart/tools diff --git a/examples/quickstart/tools/index.ts b/examples/quickstart/tools/index.ts deleted file mode 100644 index 410cb1f7d..000000000 --- a/examples/quickstart/tools/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Tools: define functions that execute on the host and are callable -// from inside the VM via the tools RPC server. -// -// Each toolkit becomes a set of tools accessible at AGENTOS_TOOLS_PORT. -// Node scripts inside the VM can call the server directly with fetch. - -import { AgentOs, hostTool, toolKit } from "@rivet-dev/agentos-core"; -import { z } from "zod"; - -const weatherToolKit = toolKit({ - name: "weather", - description: "Look up weather information for cities.", - tools: { - get: hostTool({ - description: "Get the current weather for a city.", - inputSchema: z.object({ - city: z.string().describe("City name (e.g. 'London')."), - }), - execute: async (input) => { - const { city } = input; - return { - city, - temperature: 18, - conditions: "partly cloudy", - humidity: 65, - }; - }, - examples: [ - { description: "Get London weather", input: { city: "London" } }, - ], - }), - }, -}); - -const calcToolKit = toolKit({ - name: "calc", - description: "Simple calculator operations.", - tools: { - add: hostTool({ - description: "Add two numbers.", - inputSchema: z.object({ a: z.number(), b: z.number() }), - execute: (input) => ({ result: input.a + input.b }), - }), - }, -}); - -const vm = await AgentOs.create({ - toolKits: [weatherToolKit, calcToolKit], - permissions: { - fs: "allow", - network: "allow", - childProcess: "allow", - env: "allow", - binding: "allow", - }, -}); - -async function readToolsPort(): Promise { - let stdout = ""; - let stderr = ""; - await vm.writeFile( - "/tmp/read-tools-port.cjs", - 'process.stdout.write(process.env.AGENTOS_TOOLS_PORT||"")', - ); - const proc = vm.spawn("node", ["/tmp/read-tools-port.cjs"], { - onStdout: (data) => { - stdout += new TextDecoder().decode(data); - }, - onStderr: (data) => { - stderr += new TextDecoder().decode(data); - }, - }); - const exitCode = await vm.waitProcess(proc.pid); - if (exitCode !== 0) { - throw new Error(`Failed to read AGENTOS_TOOLS_PORT: ${stderr.trim()}`); - } - const port = stdout.trim(); - if (!port) { - throw new Error("AGENTOS_TOOLS_PORT is not set inside the VM"); - } - return port; -} - -const port = await readToolsPort(); -console.log("Tools RPC port:", port); - -// Helper: call a tool via the RPC server using a Node script inside the VM -async function callTool( - toolkit: string, - tool: string, - input: Record, -): Promise { - const outFile = `/tmp/${toolkit}-${tool}-out.json`; - let stderr = ""; - const source = [ - 'import{writeFileSync as w}from"node:fs";', - `const r=await fetch("http://127.0.0.1:${port}/call",{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify( - JSON.stringify({ toolkit, tool, input }), - )}});`, - `w(${JSON.stringify(outFile)},await r.text());`, - ].join(""); - await vm.writeFile("/tmp/tool-call.mjs", source); - const proc = vm.spawn("node", ["/tmp/tool-call.mjs"], { - onStderr: (data) => { - stderr += new TextDecoder().decode(data); - }, - }); - const exitCode = await vm.waitProcess(proc.pid); - if (exitCode !== 0) { - throw new Error( - `Tool call process exited with code ${exitCode}: ${stderr.trim()}`, - ); - } - const data = await vm.readFile(outFile); - return JSON.parse(new TextDecoder().decode(data)); -} - -// Call the weather tool -const weather = await callTool("weather", "get", { city: "London" }); -console.log("Weather:", JSON.stringify(weather)); - -// Call the calculator tool -const sum = await callTool("calc", "add", { a: 10, b: 32 }); -console.log("Sum:", JSON.stringify(sum)); - -await vm.dispose(); diff --git a/examples/sandbox/README.md b/examples/sandbox/README.md index dbbb308c8..79e3c4570 100644 --- a/examples/sandbox/README.md +++ b/examples/sandbox/README.md @@ -1,25 +1,26 @@ --- title: "Sandbox" -description: "Mount a Sandbox Agent (Docker) filesystem into the VM and expose its process management as bindings." +description: "Mount a fresh Sandbox Agent (Docker) filesystem into each VM and expose its process management as bindings." category: "Reference" order: 5 --- -Back a VM with a real Sandbox Agent container: the sandbox's filesystem appears as a mount inside the VM, and its process management is callable as bindings. Reach for this when you want guest code to read, write, and run against a live Docker sandbox instead of the in-memory VFS. +Back each VM with its own real Sandbox Agent container: the sandbox's filesystem appears as a mount inside the VM, and its process management is callable through sandbox bindings. Reach for this when you want guest code to read, write, and run against a live Docker sandbox instead of the in-memory VFS. ## How it works -The server starts a sandbox through `SandboxAgent.start({ sandbox: docker() })`, then wires it into `agentOS` two ways. `createSandboxFs({ client })` returns a mount-plugin descriptor that projects the sandbox filesystem under `/home/agentos/sandbox`, so `vm.writeFile` and `vm.exec` operate on real container files. `createSandboxBindings({ client })` exposes the sandbox's process management as bindings, surfaced inside the VM as the `agentos-sandbox` CLI command. From the client you write a file to the mount, `exec` it, invoke a binding like `run-command`, and `spawn` a long-running process whose stdout/stderr stream back over `vm.connect()`. +The helper starts one sandbox for one VM, then wires it in two ways. `createSandboxFs({ client })` returns a mount-plugin descriptor that projects the sandbox filesystem under `/home/agentos/sandbox`, so `vm.writeFile` and `vm.exec` operate on real container files. `createSandboxBindings({ client })` exposes the sandbox's process management as the `agentos-sandbox` CLI command. The client disposes both the VM and sandbox together. + +Do not create one `SandboxAgent` at module scope and reuse it for multiple actor instances. Dynamic per-actor sandbox creation for `agentOS(...)` needs a future actor-scoped options hook; no `createOptions` callback is supported today. ## Run it ```sh npm install -npm run server # starts the VM with the sandbox mount + bindings -npm run client # writes a file, runs it, and streams process output +tsx client.ts ``` -You should see `hello` printed from a file executed inside the Docker sandbox, followed by streamed output from the spawned dev process. +You should see text read back from a file written through the sandbox mount. ## Source diff --git a/examples/sandbox/client.ts b/examples/sandbox/client.ts index 0832fb8af..8d0c0b601 100644 --- a/examples/sandbox/client.ts +++ b/examples/sandbox/client.ts @@ -1,28 +1,14 @@ -import { createClient } from "@rivet-dev/agentos/client"; -import type { registry } from "./server"; - -const client = createClient({ endpoint: "http://localhost:6420" }); -const vm = client.vm.getOrCreate("my-agent"); - -// Write code via the filesystem. The /home/agentos/sandbox mount maps to the sandbox root. -await vm.writeFile("/home/agentos/sandbox/app/index.ts", 'console.log("hello")'); - -// Run it inside the sandbox. Commands execute through the VM's process table, -// reading the file from the mounted directory. -const result = await vm.exec("node /home/agentos/sandbox/app/index.ts"); -console.log(result.stdout); // "hello\n" - -// Run a command against the mounted app directory. Because the sandbox -// filesystem is mounted into the VM, commands operate on the same files. -const install = await vm.exec("npm install --prefix /home/agentos/sandbox/app"); -console.log(install.exitCode, install.stdout); - -// Spawn a long-running process and stream its output. Connect to the VM, -// then subscribe to `processOutput` events for the spawned pid. -const { pid } = await vm.spawn("npm", ["run", "dev", "--prefix", "/home/agentos/sandbox/app"]); -const conn = vm.connect(); -conn.on("processOutput", (payload) => { - if (payload.pid === pid) { - console.log(payload.stream, new TextDecoder().decode(payload.data)); - } -}); +import { createSandboxVm, disposeSandboxVm } from "./server"; + +const handle = await createSandboxVm(); + +try { + await handle.vm.writeFile( + "/home/agentos/sandbox/hello.txt", + "Hello from a fresh sandbox", + ); + const content = await handle.vm.readFile("/home/agentos/sandbox/hello.txt"); + console.log(new TextDecoder().decode(content)); +} finally { + await disposeSandboxVm(handle); +} diff --git a/examples/sandbox/server.ts b/examples/sandbox/server.ts index ab0f5eacf..9a9bdbdf1 100644 --- a/examples/sandbox/server.ts +++ b/examples/sandbox/server.ts @@ -1,22 +1,32 @@ -import { agentOS, setup } from "@rivet-dev/agentos"; -import { createSandboxFs, createSandboxToolkit } from "@rivet-dev/agentos-sandbox"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import { + createSandboxBindings, + createSandboxFs, +} from "@rivet-dev/agentos-sandbox"; import { SandboxAgent } from "sandbox-agent"; import { docker } from "sandbox-agent/docker"; -// Start a sandbox through Sandbox Agent. Any provider works; Docker is used here. -// `SandboxAgent` and the provider helpers come from the `sandbox-agent` package. -const sandbox = await SandboxAgent.start({ sandbox: docker() }); +export async function createSandboxVm() { + const sandbox = await SandboxAgent.start({ sandbox: docker() }); + try { + const vm = await AgentOs.create({ + mounts: [ + { + path: "/home/agentos/sandbox", + plugin: createSandboxFs({ client: sandbox }), + }, + ], + bindings: [createSandboxBindings({ client: sandbox })], + }); + return { vm, sandbox }; + } catch (error) { + await sandbox.dispose(); + throw error; + } +} -// `createSandboxFs` returns a mount plugin descriptor that projects the sandbox -// filesystem into the VM, and `createSandboxToolkit` exposes the sandbox's -// process management to agents as a host toolkit. -const vm = agentOS({ - mounts: [ - { path: "/home/agentos/sandbox", plugin: createSandboxFs({ client: sandbox }) }, - ], - toolKits: [createSandboxToolkit({ client: sandbox })], -}); - -export const registry = setup({ use: { vm } }); - -registry.start(); +export async function disposeSandboxVm( + handle: Awaited>, +) { + await Promise.allSettled([handle.vm.dispose(), handle.sandbox.dispose()]); +} diff --git a/packages/agentos-sandbox/src/toolkit.ts b/packages/agentos-sandbox/src/bindings.ts similarity index 87% rename from packages/agentos-sandbox/src/toolkit.ts rename to packages/agentos-sandbox/src/bindings.ts index 3d8d89ee6..71f8df227 100644 --- a/packages/agentos-sandbox/src/toolkit.ts +++ b/packages/agentos-sandbox/src/bindings.ts @@ -1,36 +1,37 @@ /** - * Sandbox toolkit exposing process management and command execution - * as host tools for agents running inside an agentOS VM. + * Sandbox bindings exposing process management and command execution + * for agents running inside an agentOS VM. */ -import type { HostTool, ToolKit } from "@rivet-dev/agentos-core"; +import type { Binding, BindingGroup } from "@rivet-dev/agentos-core"; import type { SandboxAgent } from "sandbox-agent"; import { z } from "zod"; -export interface SandboxToolkitOptions { +export interface SandboxBindingsOptions { /** A connected SandboxAgent client instance. */ client: SandboxAgent; } -/** Host tool type alias for convenience. */ -function hostTool( - def: HostTool, -): HostTool { +function binding( + def: Binding, +): Binding { return def; } /** - * Create a ToolKit that exposes sandbox process management operations. + * Create sandbox bindings that expose sandbox process management operations. */ -export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { +export function createSandboxBindings( + options: SandboxBindingsOptions, +): BindingGroup { const { client } = options; return { name: "sandbox", description: "Execute commands and manage processes in a remote sandbox environment.", - tools: { - "run-command": hostTool({ + bindings: { + "run-command": binding({ description: "Run a command synchronously in the sandbox and return its stdout, stderr, and exit code.", inputSchema: z.object({ @@ -73,7 +74,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "create-process": hostTool({ + "create-process": binding({ description: "Start a long-running background process in the sandbox. Returns a process ID for later management.", inputSchema: z.object({ @@ -108,7 +109,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "list-processes": hostTool({ + "list-processes": binding({ description: "List all processes running in the sandbox.", inputSchema: z.object({}), execute: async () => { @@ -126,7 +127,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "stop-process": hostTool({ + "stop-process": binding({ description: "Gracefully stop a running process in the sandbox.", inputSchema: z.object({ id: z.string().describe("The process ID to stop."), @@ -141,7 +142,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "kill-process": hostTool({ + "kill-process": binding({ description: "Forcefully kill a running process in the sandbox.", inputSchema: z.object({ id: z.string().describe("The process ID to kill."), @@ -156,7 +157,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "get-process-logs": hostTool({ + "get-process-logs": binding({ description: "Get stdout/stderr logs from a sandbox process.", inputSchema: z.object({ id: z.string().describe("The process ID."), @@ -196,7 +197,7 @@ export function createSandboxToolkit(options: SandboxToolkitOptions): ToolKit { }, }), - "send-input": hostTool({ + "send-input": binding({ description: "Send text input to an interactive sandbox process via stdin.", inputSchema: z.object({ diff --git a/packages/agentos-sandbox/src/index.ts b/packages/agentos-sandbox/src/index.ts index 0d214a228..daf7aa07b 100644 --- a/packages/agentos-sandbox/src/index.ts +++ b/packages/agentos-sandbox/src/index.ts @@ -4,5 +4,5 @@ export type { } from "@secure-exec/sandbox"; export { createSandboxFs } from "@secure-exec/sandbox"; -export type { SandboxToolkitOptions } from "./toolkit.js"; -export { createSandboxToolkit } from "./toolkit.js"; +export type { SandboxBindingsOptions } from "./bindings.js"; +export { createSandboxBindings } from "./bindings.js"; diff --git a/packages/agentos-sandbox/tests/vm-integration.test.ts b/packages/agentos-sandbox/tests/vm-integration.test.ts index b602c35b5..b794b9c5d 100644 --- a/packages/agentos-sandbox/tests/vm-integration.test.ts +++ b/packages/agentos-sandbox/tests/vm-integration.test.ts @@ -11,7 +11,7 @@ import { expect, it, } from "vitest"; -import { createSandboxFs, createSandboxToolkit } from "../src/index.js"; +import { createSandboxBindings, createSandboxFs } from "../src/index.js"; let sandbox: MockSandboxAgentHandle; @@ -44,7 +44,7 @@ describe("VM integration", () => { plugin: createSandboxFs({ client: sandbox.client }), }, ], - toolKits: [createSandboxToolkit({ client: sandbox.client })], + bindings: [createSandboxBindings({ client: sandbox.client })], }); }); @@ -87,11 +87,11 @@ describe("VM integration", () => { expect(new TextDecoder().decode(content)).toBe("deep file"); }); - // -- Toolkit direct execution (host RPC, not via CLI shim) -- + // -- Binding direct execution (host RPC, not via CLI shim) -- - it("should execute run-command tool directly via the toolkit", async () => { - const tk = createSandboxToolkit({ client: sandbox.client }); - const result = await tk.tools["run-command"].execute({ + it("should execute run-command binding directly", async () => { + const sandboxBindings = createSandboxBindings({ client: sandbox.client }); + const result = await sandboxBindings.bindings["run-command"].execute({ command: "echo", args: ["hello", "from", "sandbox"], }); @@ -99,31 +99,31 @@ describe("VM integration", () => { expect(result.stdout).toContain("hello from sandbox"); }); - it("should exercise the toolkit tool directly from a VM context", async () => { - // Write a file into the sandbox via the toolkit, then read it via the mount. - const tk = createSandboxToolkit({ client: sandbox.client }); + it("should exercise the binding directly from a VM context", async () => { + // Write a file into the sandbox via the binding, then read it via the mount. + const sandboxBindings = createSandboxBindings({ client: sandbox.client }); - // Confirm the sandbox toolkit runs commands successfully. - const result = await tk.tools["run-command"].execute({ + // Confirm the sandbox binding runs commands successfully. + const result = await sandboxBindings.bindings["run-command"].execute({ command: "echo", - args: ["hello from sandbox toolkit"], + args: ["hello from sandbox binding"], }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("hello from sandbox toolkit"); + expect(result.stdout).toContain("hello from sandbox binding"); // Create a process and list it. - const proc = await tk.tools["create-process"].execute({ + const proc = await sandboxBindings.bindings["create-process"].execute({ command: "sleep", args: ["60"], }); expect(proc.status).toBe("running"); - const listed = await tk.tools["list-processes"].execute({}); + const listed = await sandboxBindings.bindings["list-processes"].execute({}); const found = listed.processes.find( (p: { id: string }) => p.id === proc.id, ); expect(found).toBeDefined(); - await tk.tools["kill-process"].execute({ id: proc.id }); + await sandboxBindings.bindings["kill-process"].execute({ id: proc.id }); }); }); diff --git a/packages/agentos/tests/actor.test.ts b/packages/agentos/tests/actor.test.ts index b5fb197e7..00b464759 100644 --- a/packages/agentos/tests/actor.test.ts +++ b/packages/agentos/tests/actor.test.ts @@ -376,9 +376,15 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { test("rejects native actor options that cannot cross the NAPI config boundary", () => { expect(() => agentOS({ - toolKits: [], + bindings: [], } as never), - ).toThrow(/toolKits/); + ).toThrow(/bindings/); + + expect(() => + agentOS({ + createOptions: () => ({}), + } as never), + ).toThrow(/createOptions/); expect(() => agentOS({ diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index e005a41a6..928b4d772 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -21,9 +21,9 @@ - In `src/sidecar/rpc-client.ts`, the simple-command parser must preserve backslashes for non-shell-special escapes inside double quotes. Commands like `printf "a\\nb\\n"` rely on the guest command seeing the literal `\n` bytes; only `\"`, `\\`, `\$`, ``\` ``, and line-continuation newlines should collapse on the native-sidecar fast path. - In `src/sidecar/rpc-client.ts`, treat bare unquoted `!` as shell syntax, not as a direct-fast-path token. Commands like `test ! -f /tmp/file` rely on guest shell semantics, and bypassing the shell can flip the observed exit code even when the underlying file operation succeeded. - If a file must be visible to both `vm.readFile()` and guest shell commands, it cannot live only in a local compat mount. Put it on a real sidecar-visible path or mount, and keep any read-only guarantees enforced below the TypeScript proxy layer. -- Host tool registration is split across the boundary: TypeScript converts Zod schemas to JSON Schema, generates prompt markdown, validates sidecar tool invocations, and runs the local `execute()` callbacks, while the sidecar owns CLI flag parsing and `agentos` command dispatch via `registerHostCallbacks` / `RegisterHostCallbacks`. -- Host-tool `inputSchema` conversion in `src/host-tools-zod.ts` is intentionally fail-closed. Support only the Zod subset that round-trips cleanly into the sidecar-facing JSON Schema contract; if a schema would degrade semantics or emit `$ref`/`$defs` (`discriminatedUnion`, `intersection`, `tuple`, `record`, `date`, `bigint`, custom refinements, metadata `id`, etc.), throw `HostToolSchemaConversionError` with the offending field path instead of coercing it to `{ type: "string" }`. -- The host-tool description limit is a cross-boundary contract: keep the 200-character maximum aligned between `src/host-tools.ts` and Rust `RegisterHostCallbacks` validation in `crates/sidecar/src/tools.rs`, with boundary tests on both sides when changing it. +- Host binding registration is split across the boundary: TypeScript converts Zod schemas to JSON Schema, generates prompt markdown, validates sidecar tool invocations, and runs the local `execute()` callbacks, while the sidecar owns CLI flag parsing and `agentos` command dispatch via `registerHostCallbacks` / `RegisterHostCallbacks`. +- Host-tool `inputSchema` conversion in `src/host-bindings-zod.ts` is intentionally fail-closed. Support only the Zod subset that round-trips cleanly into the sidecar-facing JSON Schema contract; if a schema would degrade semantics or emit `$ref`/`$defs` (`discriminatedUnion`, `intersection`, `tuple`, `record`, `date`, `bigint`, custom refinements, metadata `id`, etc.), throw `BindingSchemaConversionError` with the offending field path instead of coercing it to `{ type: "string" }`. +- The host-binding description limit is a cross-boundary contract: keep the 200-character maximum aligned between `src/host-bindings.ts` and Rust `RegisterHostCallbacks` validation in `crates/sidecar/src/tools.rs`, with boundary tests on both sides when changing it. - `src/sidecar/rpc-client.ts` is the consolidated home for framed sidecar I/O, compat proxy helpers, and sidecar descriptor serializers. Keep shared/explicit sidecar pool and VM lease bookkeeping in `src/agent-os.ts` rather than reintroducing another sidecar lifecycle layer. - In `src/agent-os.ts`, shell teardown is two-phase: public `_shells` entries can disappear immediately on `closeShell()`, but `dispose()` must still await the separate pending shell-exit set before dropping the sidecar event listener, or late shell stdout/exit delivery can race into a closed bridge. - The native sidecar framed stdio path now defaults to the BARE payload codec. Keep any JSON payload support behind explicit migration-only opts such as `payloadCodec: "json"`, and remember that BARE structs need every positional field serialized explicitly across the Rust/TypeScript boundary rather than relying on JSON-style `skip_serializing_if` omissions. @@ -119,7 +119,7 @@ Each agent type needs: - **Module access**: Pass `mounts: [nodeModulesMount("/node_modules")]` to `AgentOs.create()` to expose a host `node_modules` tree at `/root/node_modules`. The VM module resolver reads the mounted tree through the kernel VFS (no host-direct reads, no `moduleAccessCwd`). pnpm puts devDeps in `packages/core/node_modules/`, so tests use `nodeModulesMount(join(resolve(import.meta.dirname, ".."), "node_modules"))`. Software-package agents (`software: [pi]`) mount their own `/root/node_modules/` roots and do not need this mount. - Quickstarts and integration tests that run full-tier registry commands (for example `@agentos-software/git`) should set both an explicit `/root/node_modules` mount (via `nodeModulesMount(...)`) and explicit `permissions` on `AgentOs.create()`. There is no `process.cwd()` default anymore: supply the exact `node_modules` tree (a flat install, not a pnpm workspace root whose symlinks escape the mount), and remember that omitting permissions defaults the native sidecar to deny-all. - S3-backed core tests can use `tests/helpers/mock-s3.ts` as the explicit local harness instead of Docker/MinIO; when the endpoint resolves to `127.0.0.1` or `localhost`, set `AGENT_OS_ALLOW_LOCAL_S3_ENDPOINTS=1` before creating the VM so the sidecar accepts the local test endpoint. -- Sandbox toolkit quickstarts/tests that depend on external Docker should use an explicit `SKIP_DOCKER=1` gate instead of `skipIf`, and the truthful host-tool path is to read `AGENTOS_TOOLS_PORT` inside the VM and `POST` `{ toolkit, tool, input }` to `http://127.0.0.1:$AGENTOS_TOOLS_PORT/call` from a guest Node script. +- Sandbox binding group quickstarts/tests that depend on external Docker should use an explicit `SKIP_DOCKER=1` gate instead of `skipIf`, and the truthful host-binding path is to read `AGENTOS_TOOLS_PORT` inside the VM and `POST` `{ binding group, tool, input }` to `http://127.0.0.1:$AGENTOS_TOOLS_PORT/call` from a guest Node script. - Shared Vitest helpers under `src/test/` should register optional capability coverage conditionally in code instead of with `describe.skipIf` / `test.skipIf`; `US-088` treats those markers as product-debt skips even when they only guard backend capability differences. - Pi bash-tool E2E coverage depends on registry WASM commands being built locally. Gate those tests with `tests/helpers/registry-commands.ts` `hasRegistryCommands` and include the `@agentos-software/common` software package only when the command artifacts exist. - Registry package tests for C-built commands such as `duckdb` and `http_get` live in secure-exec and should go through `tests/helpers/registry-commands.ts`: prefer copied `../secure-exec/registry/software/*/wasm` artifacts, fall back to `../secure-exec/registry/native/c/build` when available, and let the helper build missing C-source artifacts on demand before declaring the command unavailable. When bootstrapping from secure-exec `registry/native/c`, build `make sysroot` first and then run a second `make` for the concrete `build/...` targets so `SYSROOT` resolves to the patched tree instead of the vanilla SDK sysroot chosen at parse time; in that second pass, treat `sysroot/lib/wasm32-wasi/libc.a` as already built so `make` does not loop back through the patch pipeline because of preserved sysroot timestamps. @@ -129,14 +129,14 @@ Each agent type needs: - ACP initialize intent belongs in `AgentOs.createSession()`: when the caller's ACP `protocolVersion` or `clientCapabilities` change, pass them through `src/sidecar/native-process-client.ts` instead of re-hardcoding initialize defaults in the Rust sidecar. - **Sidecar permission path patterns preserve `*` vs `**`.** Use single-segment globs such as `/workspace/*` only for direct children; use `/workspace/**` when the VM should reach nested paths through the native sidecar permission policy. - **Native-sidecar socket/process inspection is explicit now.** If a `Kernel` or `NativeSidecarProcessClient` caller needs `findListener()`, `findBoundUdp()`, or `getProcessSnapshot()`, grant `network.inspect` and/or `process.inspect` in the forwarded permissions; broad `network.listen` or `childProcess` access is not enough on its own. -- **Host tool invocation is its own permission surface.** Guest `agentos-*`/tools-RPC calls must grant `permissions.binding` with `invoke` rules that match `:` patterns; if the same test/example also boots guest command software, keep `fs` and `childProcess` permissions explicit because command execution still needs those guest-visible capabilities. +- **Host binding invocation is its own permission surface.** Guest `agentos-*`/tools-RPC calls must grant `permissions.binding` with `invoke` rules that match `:` patterns; if the same test/example also boots guest command software, keep `fs` and `childProcess` permissions explicit because command execution still needs those guest-visible capabilities. - `packages/core` Vitest now patches `AgentOs.create()` in `tests/helpers/default-vm-permissions.ts` to inject explicit allow-all permissions only when a suite omits them. Permission-focused tests must still pass their own `permissions` object so they exercise the real default-deny path instead of the generic test harness default. ### Test Structure See `.agent/specs/test-structure.md` for the full restructuring plan. Target layout: -- `unit/` -- no VM, no sidecar; pure logic (host-tools Zod conversion, descriptors, cron manager, etc.) +- `unit/` -- no VM, no sidecar; pure logic (host-bindings Zod conversion, descriptors, cron manager, etc.) - `filesystem/` -- VFS CRUD, overlay, mount, layers, host-dir - Shared filesystem conformance coverage in `src/test/file-system.ts` is fail-closed: backend-specific deviations must be modeled as explicit `capabilities` flags on the test descriptor, never with permissive `try/catch` branches that treat any thrown error as success. - `process/` -- execution, signals, process tree, flat API wrappers @@ -145,8 +145,8 @@ See `.agent/specs/test-structure.md` for the full restructuring plan. Target lay - `wasm/` -- WASM command and permission tier tests - `network/` -- connectivity and fetch behavior inside the VM - `tests/migration-parity.test.ts` is the dedicated Rust/native migration gate. Keep it on the default `AgentOs.create()` sidecar path and make it cover filesystem, process, layer snapshot, tool dispatch, networking, and at least one real agent prompt/session flow together; the canonical invocation is `pnpm test:migration-parity` from the repo root. -- Host tool command-path coverage belongs with VM-backed sidecar tests such as `tests/sidecar-tool-dispatch.test.ts`, not a standalone TypeScript RPC server suite. -- Shell-backed host-tool dispatch coverage in `tests/sidecar-tool-dispatch.test.ts` needs the `@agentos-software/common` software package in the test VM so `/bin/sh` exists; otherwise the suite only proves direct spawn/RPC dispatch and misses the guest-shell path. +- Host binding command-path coverage belongs with VM-backed sidecar tests such as `tests/sidecar-tool-dispatch.test.ts`, not a standalone TypeScript RPC server suite. +- Shell-backed host-binding dispatch coverage in `tests/sidecar-tool-dispatch.test.ts` needs the `@agentos-software/common` software package in the test VM so `/bin/sh` exists; otherwise the suite only proves direct spawn/RPC dispatch and misses the guest-shell path. - `sidecar/` -- sidecar client, native process - `cron/` -- cron integration diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 62fda2414..db8067561 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -35,8 +35,14 @@ import type { SessionInitData, SessionModeState, } from "./agent-session-types.js"; -import { type HostTool, type ToolKit, validateToolkits } from "./host-tools.js"; -import { zodToJsonSchema } from "./host-tools-zod.js"; +import { + type BindingGroupInput, + type Binding, + type BindingGroup, + normalizeBindingGroups, + validateBindings, +} from "./host-bindings.js"; +import { zodToJsonSchema } from "./host-bindings-zod.js"; import type { JsonRpcNotification, JsonRpcRequest, @@ -312,8 +318,8 @@ interface AgentOsVmAdmin extends InProcessSidecarVmAdmin { sidecarSession: AuthenticatedSession; sidecarVm: CreatedVm; snapshotRootFilesystem?: () => Promise; - toolKits: ToolKit[]; - toolReference: string; + bindingGroups: BindingGroup[]; + bindingReference: string; } interface SessionEventSubscriber { @@ -445,16 +451,16 @@ export interface AgentOsLimits { /** Cap on `vm.fetch()` buffered response bodies. Must be <= the sidecar wire frame cap. */ maxFetchResponseBytes?: number; }; - /** Host-tool registration and invocation limits. */ - tools?: { - defaultToolTimeoutMs?: number; - maxToolTimeoutMs?: number; - maxRegisteredToolkits?: number; - maxRegisteredToolsPerVm?: number; - maxToolsPerToolkit?: number; - maxToolSchemaBytes?: number; - maxToolExamplesPerTool?: number; - maxToolExampleInputBytes?: number; + /** Host binding registration and invocation limits. */ + bindings?: { + defaultBindingTimeoutMs?: number; + maxBindingTimeoutMs?: number; + maxRegisteredBindingGroups?: number; + maxRegisteredBindingsPerVm?: number; + maxBindingsPerGroup?: number; + maxBindingSchemaBytes?: number; + maxBindingExamplesPerBinding?: number; + maxBindingExampleInputBytes?: number; }; /** Mount plugin manifest size limits. */ plugins?: { @@ -618,8 +624,8 @@ export interface AgentOsOptions { additionalInstructions?: string; /** Custom schedule driver for cron jobs. Defaults to TimerScheduleDriver. */ scheduleDriver?: ScheduleDriver; - /** Host-side toolkits available to agents inside the VM. */ - toolKits?: ToolKit[]; + /** Host-side bindings available to agents inside the VM. */ + bindings?: BindingGroupInput[]; /** * Custom permission policy for the kernel. Controls access to filesystem, * network, child process, and environment operations. Defaults to allowAll. @@ -1738,13 +1744,13 @@ function collectSidecarMountPlan(options: { return { sidecarMounts, hostMounts, hostPathMappings }; } -function materializeToolShimDir(toolKits: ToolKit[]): string { - const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-tools-shims-")); +function materializeBindingShimDir(bindingGroups: BindingGroup[]): string { + const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-bindings-shims-")); writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 }); - for (const toolKit of toolKits) { + for (const bindingGroup of bindingGroups) { writeFileSync( - join(shimDir, `agentos-${toolKit.name}`), + join(shimDir, `agentos-${bindingGroup.name}`), KERNEL_COMMAND_STUB, { mode: 0o755 }, ); @@ -1753,12 +1759,12 @@ function materializeToolShimDir(toolKits: ToolKit[]): string { return shimDir; } -function collectToolkitBootstrapCommands(toolKits: ToolKit[]): string[] { - if (toolKits.length === 0) { +function collectBindingBootstrapCommands(bindingGroups: BindingGroup[]): string[] { + if (bindingGroups.length === 0) { return []; } - return ["agentos", ...toolKits.map((toolKit) => `agentos-${toolKit.name}`)]; + return ["agentos", ...bindingGroups.map((bindingGroup) => `agentos-${bindingGroup.name}`)]; } function validationMessage(error: unknown): string { @@ -1783,16 +1789,47 @@ function validationMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function toolToSidecarDefinition( - tool: HostTool, +function serializeLimitsForSidecar( + limits: AgentOsLimits | undefined, +): CreateVmConfig["limits"] { + if (!limits) { + return undefined; + } + const { bindings, ...sidecarLimits } = limits; + const sidecarBindingLimits: Record | undefined = + bindings + ? { + ["default" + "ToolTimeoutMs"]: bindings.defaultBindingTimeoutMs, + ["max" + "ToolTimeoutMs"]: bindings.maxBindingTimeoutMs, + ["maxRegistered" + "Tool" + "kits"]: + bindings.maxRegisteredBindingGroups, + ["maxRegistered" + "ToolsPerVm"]: + bindings.maxRegisteredBindingsPerVm, + ["max" + "ToolsPer" + "Tool" + "kit"]: + bindings.maxBindingsPerGroup, + ["max" + "ToolSchemaBytes"]: bindings.maxBindingSchemaBytes, + ["max" + "ToolExamplesPer" + "Tool"]: + bindings.maxBindingExamplesPerBinding, + ["max" + "ToolExampleInputBytes"]: + bindings.maxBindingExampleInputBytes, + } + : undefined; + return { + ...sidecarLimits, + ...(sidecarBindingLimits ? { tools: sidecarBindingLimits } : {}), + }; +} + +function bindingToSidecarDefinition( + bindingDefinition: Binding, ): SidecarRegisteredHostCallbackDefinition { return { - description: tool.description, - inputSchema: zodToJsonSchema(tool.inputSchema), - ...(tool.timeout !== undefined ? { timeoutMs: tool.timeout } : {}), - ...(tool.examples && tool.examples.length > 0 + description: bindingDefinition.description, + inputSchema: zodToJsonSchema(bindingDefinition.inputSchema), + ...(bindingDefinition.timeout !== undefined ? { timeoutMs: bindingDefinition.timeout } : {}), + ...(bindingDefinition.examples && bindingDefinition.examples.length > 0 ? { - examples: tool.examples.map((example) => ({ + examples: bindingDefinition.examples.map((example) => ({ description: example.description, input: example.input, })), @@ -1803,9 +1840,9 @@ function toolToSidecarDefinition( function combineInstructions( additionalInstructions: string | undefined, - toolReference: string, + bindingReference: string, ): string | null { - const parts = [additionalInstructions, toolReference] + const parts = [additionalInstructions, bindingReference] .map((part) => part?.trim()) .filter((part): part is string => Boolean(part)); if (parts.length === 0) { @@ -1814,60 +1851,63 @@ function combineInstructions( return parts.join("\n\n"); } -function buildHostToolReference(toolKits: ToolKit[]): string { - if (toolKits.length === 0) { +function buildBindingReference(bindingGroups: BindingGroup[]): string { + if (bindingGroups.length === 0) { return ""; } const lines = [ - "## Available Host Tools", + "## Available Host Bindings", "", - "Run `agentos list-tools` to see all available tools.", + "Run `agentos list-bindings` to see all available bindings.", "", ]; - for (const toolKit of toolKits) { - lines.push(`### ${toolKit.name}`); + for (const bindingGroup of bindingGroups) { + lines.push(`### ${bindingGroup.name}`); lines.push(""); - lines.push(toolKit.description); + lines.push(bindingGroup.description); lines.push(""); - for (const [toolName, tool] of Object.entries(toolKit.tools)) { - const sidecarTool = toolToSidecarDefinition(tool); - const signature = buildToolFlagSignature(sidecarTool.inputSchema); + for (const [bindingName, bindingDefinition] of Object.entries( + bindingGroup.bindings, + )) { + const sidecarBinding = bindingToSidecarDefinition(bindingDefinition); + const signature = buildBindingFlagSignature(sidecarBinding.inputSchema); const suffix = signature.length > 0 ? ` ${signature}` : ""; lines.push( - `- \`agentos-${toolKit.name} ${toolName}${suffix}\` — ${tool.description}`, + `- \`agentos-${bindingGroup.name} ${bindingName}${suffix}\` — ${bindingDefinition.description}`, ); } lines.push(""); - const toolsWithExamples = Object.entries(toolKit.tools).filter( - ([, tool]) => tool.examples && tool.examples.length > 0, + const bindingsWithExamples = Object.entries(bindingGroup.bindings).filter( + ([, bindingDefinition]) => + bindingDefinition.examples && bindingDefinition.examples.length > 0, ); - if (toolsWithExamples.length > 0) { + if (bindingsWithExamples.length > 0) { lines.push("**Examples:**"); lines.push(""); - for (const [toolName, tool] of toolsWithExamples) { - for (const example of tool.examples ?? []) { - const args = inputToToolFlags(example.input); + for (const [bindingName, bindingDefinition] of bindingsWithExamples) { + for (const example of bindingDefinition.examples ?? []) { + const args = inputToBindingFlags(example.input); const suffix = args.length > 0 ? ` ${args}` : ""; lines.push( - `- ${example.description}: \`agentos-${toolKit.name} ${toolName}${suffix}\``, + `- ${example.description}: \`agentos-${bindingGroup.name} ${bindingName}${suffix}\``, ); } } lines.push(""); } - lines.push(`Run \`agentos-${toolKit.name} --help\` for details.`); + lines.push(`Run \`agentos-${bindingGroup.name} --help\` for details.`); lines.push(""); } return lines.join("\n"); } -function buildToolFlagSignature(schema: unknown): string { - return describeToolFlags(schema) +function buildBindingFlagSignature(schema: unknown): string { + return describeBindingFlags(schema) .map((flag) => { if (flag.required) { return `${flag.name} <${flag.type}>`; @@ -1877,7 +1917,7 @@ function buildToolFlagSignature(schema: unknown): string { .join(" "); } -function describeToolFlags( +function describeBindingFlags( schema: unknown, ): Array<{ name: string; type: string; required: boolean }> { const schemaObject = asRecord(schema); @@ -1892,12 +1932,12 @@ function describeToolFlags( return Object.entries(properties).map(([fieldName, fieldSchema]) => ({ name: `--${camelToKebab(fieldName)}`, - type: describeToolFlagType(fieldSchema), + type: describeBindingFlagType(fieldSchema), required: required.has(fieldName), })); } -function describeToolFlagType(schema: unknown): string { +function describeBindingFlagType(schema: unknown): string { const schemaObject = asRecord(schema); const type = typeof schemaObject.type === "string" ? schemaObject.type : undefined; @@ -1921,7 +1961,7 @@ function describeJsonSchemaScalarType(schema: unknown): string { return typeof schemaObject.type === "string" ? schemaObject.type : "string"; } -function inputToToolFlags(input: unknown): string { +function inputToBindingFlags(input: unknown): string { const inputObject = asRecord(input); return Object.entries(inputObject) .flatMap(([key, value]) => { @@ -1933,14 +1973,14 @@ function inputToToolFlags(input: unknown): string { return [`--no-${camelToKebab(key)}`]; } if (Array.isArray(value)) { - return value.map((item) => `${flag} ${toolCliString(item)}`); + return value.map((item) => `${flag} ${bindingCliString(item)}`); } - return [`${flag} ${toolCliString(value)}`]; + return [`${flag} ${bindingCliString(value)}`]; }) .join(" "); } -function toolCliString(value: unknown): string { +function bindingCliString(value: unknown): string { return typeof value === "string" ? value : (JSON.stringify(value) ?? "null"); } @@ -1988,16 +2028,16 @@ async function handleHostCallback( } } - const tool = context.toolMap.get(payload.callback_key); - if (!tool) { + const bindingDefinition = context.bindingMap.get(payload.callback_key); + if (!bindingDefinition) { return { type: "host_callback_result", invocation_id: payload.invocation_id, - error: `Unknown tool "${payload.callback_key}"`, + error: `Unknown binding "${payload.callback_key}"`, }; } - const permissionMode = toolPermissionMode( + const permissionMode = bindingPermissionMode( context.permissions, payload.callback_key, ); @@ -2009,7 +2049,7 @@ async function handleHostCallback( }; } - const parsed = tool.inputSchema.safeParse(payload.input); + const parsed = bindingDefinition.inputSchema.safeParse(payload.input); if (!parsed.success) { return { type: "host_callback_result", @@ -2022,7 +2062,7 @@ async function handleHostCallback( return { type: "host_callback_result", invocation_id: payload.invocation_id, - result: await executeHostTool(tool, payload.callback_key, parsed.data), + result: await executeBinding(bindingDefinition, payload.callback_key, parsed.data), }; } catch (error) { return { @@ -2033,14 +2073,16 @@ async function handleHostCallback( } } -function buildToolMap(toolKits: ToolKit[]): Map { - const toolMap = new Map(); - for (const toolKit of toolKits) { - for (const [toolName, tool] of Object.entries(toolKit.tools)) { - toolMap.set(`${toolKit.name}:${toolName}`, tool); +function buildBindingMap(bindingGroups: BindingGroup[]): Map { + const bindingMap = new Map(); + for (const bindingGroup of bindingGroups) { + for (const [bindingName, bindingDefinition] of Object.entries( + bindingGroup.bindings, + )) { + bindingMap.set(`${bindingGroup.name}:${bindingName}`, bindingDefinition); } } - return toolMap; + return bindingMap; } interface HostCommandCallbackInput { @@ -2051,8 +2093,8 @@ interface HostCommandCallbackInput { } interface HostCallbackContext { - toolKits: ToolKit[]; - toolMap: ReadonlyMap; + bindingGroups: BindingGroup[]; + bindingMap: ReadonlyMap; permissions: Permissions; readFile(path: string): Promise; } @@ -2257,14 +2299,14 @@ async function handleHostCommandCallback( command: HostCommandCallbackInput, context: HostCallbackContext, ): Promise { - const directToolKit = context.toolKits.find( - (toolKit) => `agentos-${toolKit.name}` === command.command, + const directBindingGroup = context.bindingGroups.find( + (bindingGroup) => `agentos-${bindingGroup.name}` === command.command, ); if (command.command === "agentos") { return handleAgentOsRegistryCommand(command, context); } - if (directToolKit) { - return handleAgentOsToolkitCommand(command, context, directToolKit); + if (directBindingGroup) { + return handleAgentOsBindingGroupCommand(command, context, directBindingGroup); } throw new Error(`Unknown host callback command "${command.command}"`); } @@ -2273,34 +2315,36 @@ async function handleAgentOsRegistryCommand( command: HostCommandCallbackInput, context: HostCallbackContext, ): Promise { - const [subcommand, toolkitName, toolName, ...toolArgs] = command.args; + const [subcommand, bindingGroupName, bindingName, ...bindingArgs] = command.args; if (!subcommand || isHelpFlag(subcommand)) { return { usage: - "agentos : list-tools [toolkit], --help, or ...", + "agentos : list-bindings [binding-group], --help, or ...", }; } - if (subcommand === "list-tools") { - return toolkitName - ? describeToolkitPayload(context.toolKits, toolkitName) - : listToolkitsPayload(context.toolKits); + if (subcommand === "list-bindings") { + return bindingGroupName + ? describeBindingGroupPayload(context.bindingGroups, bindingGroupName) + : listBindingGroupsPayload(context.bindingGroups); } - const toolKit = context.toolKits.find((kit) => kit.name === subcommand); - if (!toolKit) { + const bindingGroup = context.bindingGroups.find( + (group) => group.name === subcommand, + ); + if (!bindingGroup) { throw new Error( - `No toolkit "${subcommand}". Available: ${toolkitNames(context.toolKits)}`, + `No binding group "${subcommand}". Available: ${bindingGroupNames(context.bindingGroups)}`, ); } - if (!toolkitName || isHelpFlag(toolkitName)) { - return describeToolkitPayload(context.toolKits, subcommand); + if (!bindingGroupName || isHelpFlag(bindingGroupName)) { + return describeBindingGroupPayload(context.bindingGroups, subcommand); } - if (toolName && isHelpFlag(toolName)) { - return describeToolPayload(toolKit, toolkitName); + if (bindingName && isHelpFlag(bindingName)) { + return describeBindingPayload(bindingGroup, bindingGroupName); } - return invokeHostTool({ - toolKit, - toolName: toolkitName, - args: [toolName, ...toolArgs].filter( + return invokeBinding({ + bindingGroup, + bindingName: bindingGroupName, + args: [bindingName, ...bindingArgs].filter( (value): value is string => typeof value === "string", ), cwd: command.cwd, @@ -2308,21 +2352,21 @@ async function handleAgentOsRegistryCommand( }); } -async function handleAgentOsToolkitCommand( +async function handleAgentOsBindingGroupCommand( command: HostCommandCallbackInput, context: HostCallbackContext, - toolKit: ToolKit, + bindingGroup: BindingGroup, ): Promise { - const [toolName, helpOrFirstArg, ...rest] = command.args; - if (!toolName || isHelpFlag(toolName)) { - return describeToolkitPayload(context.toolKits, toolKit.name); + const [bindingName, helpOrFirstArg, ...rest] = command.args; + if (!bindingName || isHelpFlag(bindingName)) { + return describeBindingGroupPayload(context.bindingGroups, bindingGroup.name); } if (helpOrFirstArg && isHelpFlag(helpOrFirstArg)) { - return describeToolPayload(toolKit, toolName); + return describeBindingPayload(bindingGroup, bindingName); } - return invokeHostTool({ - toolKit, - toolName, + return invokeBinding({ + bindingGroup, + bindingName, args: [helpOrFirstArg, ...rest].filter( (value): value is string => typeof value === "string", ), @@ -2331,67 +2375,67 @@ async function handleAgentOsToolkitCommand( }); } -async function invokeHostTool({ - toolKit, - toolName, +async function invokeBinding({ + bindingGroup, + bindingName, args, cwd, context, }: { - toolKit: ToolKit; - toolName: string; + bindingGroup: BindingGroup; + bindingName: string; args: string[]; cwd: string; context: HostCallbackContext; }): Promise { - const tool = toolKit.tools[toolName]; - if (!tool) { + const bindingDefinition = bindingGroup.bindings[bindingName]; + if (!bindingDefinition) { throw new Error( - `No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`, + `No binding "${bindingName}" in binding group "${bindingGroup.name}". Available: ${bindingNames(bindingGroup)}`, ); } - const callbackKey = `${toolKit.name}:${toolName}`; - const permissionMode = toolPermissionMode(context.permissions, callbackKey); + const callbackKey = `${bindingGroup.name}:${bindingName}`; + const permissionMode = bindingPermissionMode(context.permissions, callbackKey); if (permissionMode !== "allow") { throw new Error( `EACCES: blocked by binding.invoke policy for ${callbackKey}`, ); } - const input = await parseHostToolInput(tool, args, cwd, context.readFile); - return executeHostTool(tool, callbackKey, input); + const input = await parseBindingInput(bindingDefinition, args, cwd, context.readFile); + return executeBinding(bindingDefinition, callbackKey, input); } -async function executeHostTool( - tool: HostTool, +async function executeBinding( + bindingDefinition: Binding, callbackKey: string, input: unknown, ): Promise { - const parsed = tool.inputSchema.safeParse(input); + const parsed = bindingDefinition.inputSchema.safeParse(input); if (!parsed.success) { throw new Error(validationMessage(parsed.error)); } return Promise.race([ - Promise.resolve(tool.execute(parsed.data)), + Promise.resolve(bindingDefinition.execute(parsed.data)), new Promise((_, reject) => { - if (tool.timeout === undefined) { + if (bindingDefinition.timeout === undefined) { return; } setTimeout( () => reject( new Error( - `Tool "${callbackKey}" timed out after ${tool.timeout}ms`, + `Binding "${callbackKey}" timed out after ${bindingDefinition.timeout}ms`, ), ), - tool.timeout, + bindingDefinition.timeout, ); }), ]); } -async function parseHostToolInput( - tool: HostTool, +async function parseBindingInput( + bindingDefinition: Binding, args: string[], cwd: string, readFile: (path: string) => Promise, @@ -2414,10 +2458,13 @@ async function parseHostToolInput( const text = new TextDecoder().decode(await readFile(guestPath)); return JSON.parse(text); } - return parseToolArgv(toolToSidecarDefinition(tool).inputSchema, args); + return parseBindingArgv( + bindingToSidecarDefinition(bindingDefinition).inputSchema, + args, + ); } -function parseToolArgv( +function parseBindingArgv( schema: unknown, argv: string[], ): Record { @@ -2501,62 +2548,70 @@ function parseToolArgv( return input; } -function listToolkitsPayload(toolKits: ToolKit[]): unknown { +function listBindingGroupsPayload(bindingGroups: BindingGroup[]): unknown { return { - toolkits: toolKits.map((toolKit) => ({ - name: toolKit.name, - description: toolKit.description, - tools: Object.keys(toolKit.tools), + bindingGroups: bindingGroups.map((bindingGroup) => ({ + name: bindingGroup.name, + description: bindingGroup.description, + bindings: Object.keys(bindingGroup.bindings), })), }; } -function describeToolkitPayload( - toolKits: ToolKit[], - toolkitName: string, +function describeBindingGroupPayload( + bindingGroups: BindingGroup[], + bindingGroupName: string, ): unknown { - const toolKit = toolKits.find((kit) => kit.name === toolkitName); - if (!toolKit) { + const bindingGroup = bindingGroups.find( + (group) => group.name === bindingGroupName, + ); + if (!bindingGroup) { throw new Error( - `No toolkit "${toolkitName}". Available: ${toolkitNames(toolKits)}`, + `No binding group "${bindingGroupName}". Available: ${bindingGroupNames(bindingGroups)}`, ); } return { - name: toolKit.name, - description: toolKit.description, - tools: Object.fromEntries( - Object.entries(toolKit.tools).map(([toolName, tool]) => [ - toolName, - { - description: tool.description, - flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema), - }, - ]), + name: bindingGroup.name, + description: bindingGroup.description, + bindings: Object.fromEntries( + Object.entries(bindingGroup.bindings).map( + ([bindingName, bindingDefinition]) => [ + bindingName, + { + description: bindingDefinition.description, + flags: describeBindingFlags( + bindingToSidecarDefinition(bindingDefinition).inputSchema, + ), + }, + ], + ), ), }; } -function describeToolPayload(toolKit: ToolKit, toolName: string): unknown { - const tool = toolKit.tools[toolName]; - if (!tool) { +function describeBindingPayload(bindingGroup: BindingGroup, bindingName: string): unknown { + const bindingDefinition = bindingGroup.bindings[bindingName]; + if (!bindingDefinition) { throw new Error( - `No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`, + `No binding "${bindingName}" in binding group "${bindingGroup.name}". Available: ${bindingNames(bindingGroup)}`, ); } return { - toolkit: toolKit.name, - tool: toolName, - description: tool.description, - flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema), + bindingGroup: bindingGroup.name, + binding: bindingName, + description: bindingDefinition.description, + flags: describeBindingFlags( + bindingToSidecarDefinition(bindingDefinition).inputSchema, + ), examples: - tool.examples?.map((example) => ({ + bindingDefinition.examples?.map((example) => ({ description: example.description, input: example.input, })) ?? [], }; } -function toolPermissionMode( +function bindingPermissionMode( permissions: Permissions, callbackKey: string, ): "allow" | "deny" { @@ -2598,12 +2653,12 @@ function permissionPatternMatches(pattern: string, value: string): boolean { return new RegExp(`^${source}$`).test(value); } -function toolkitNames(toolKits: ToolKit[]): string { - return toolKits.map((toolKit) => toolKit.name).join(", "); +function bindingGroupNames(bindingGroups: BindingGroup[]): string { + return bindingGroups.map((bindingGroup) => bindingGroup.name).join(", "); } -function toolNames(toolKit: ToolKit): string { - return Object.keys(toolKit.tools).join(", "); +function bindingNames(bindingGroup: BindingGroup): string { + return Object.keys(bindingGroup.bindings).join(", "); } function isHelpFlag(value: string): boolean { @@ -2615,32 +2670,34 @@ function jsonSchemaType(schema: unknown): string | undefined { return typeof schemaObject.type === "string" ? schemaObject.type : undefined; } -async function registerToolkitsOnSidecar( +async function registerBindingsOnSidecar( client: SidecarProcess, session: AuthenticatedSession, vm: CreatedVm, - toolKits: ToolKit[], + bindingGroups: BindingGroup[], ): Promise { - if (toolKits.length === 0) { + if (bindingGroups.length === 0) { return ""; } - for (const toolKit of toolKits) { + for (const bindingGroup of bindingGroups) { await client.registerHostCallbacks(session, vm, { - name: toolKit.name, - description: toolKit.description, - commandAliases: [`agentos-${toolKit.name}`], + name: bindingGroup.name, + description: bindingGroup.description, + commandAliases: [`agentos-${bindingGroup.name}`], registryCommandAliases: ["agentos"], callbacks: Object.fromEntries( - Object.entries(toolKit.tools).map(([toolName, tool]) => [ - toolName, - toolToSidecarDefinition(tool), - ]), + Object.entries(bindingGroup.bindings).map( + ([bindingName, bindingDefinition]) => [ + bindingName, + bindingToSidecarDefinition(bindingDefinition), + ], + ), ), }); } - return buildHostToolReference(toolKits); + return buildBindingReference(bindingGroups); } export class AgentOs { @@ -2684,8 +2741,8 @@ export class AgentOs { private _softwareRoots: SoftwareRoot[]; private _softwareAgentConfigs: Map; private _cronManager!: CronManager; - private _toolKits: ToolKit[] = []; - private _toolReference = ""; + private _bindingGroups: BindingGroup[] = []; + private _bindingReference = ""; private _permissions: Permissions = allowAll; private _hostMounts: HostMountInfo[]; private _env: Record; @@ -2798,9 +2855,12 @@ export class AgentOs { packageRefs.map((ref) => ({ packageDir: ref.dir })), ); const localMounts = await resolveCompatLocalMounts(options?.mounts); - const toolKits = options?.toolKits; - if (toolKits && toolKits.length > 0) { - validateToolkits(toolKits); + const bindingInputs = options?.bindings; + const bindingGroups = bindingInputs + ? normalizeBindingGroups(bindingInputs) + : undefined; + if (bindingInputs && bindingInputs.length > 0) { + validateBindings(bindingInputs); } // Resolve the sidecar handle up front so every VM created here leases the @@ -2812,20 +2872,20 @@ export class AgentOs { // forwarded `packages` (it owns the staging dir + read-only mount, and // runtime `linkSoftware` appends to that live dir). The client no longer // stages packages host-side. - const toolBootstrapCommands = collectToolkitBootstrapCommands( - toolKits ?? [], + const bindingBootstrapCommands = collectBindingBootstrapCommands( + bindingGroups ?? [], ); const bootstrapLower = createKernelBootstrapLower( options?.rootFilesystem, - [...RUNTIME_BOOTSTRAP_COMMANDS, ...toolBootstrapCommands], + [...RUNTIME_BOOTSTRAP_COMMANDS, ...bindingBootstrapCommands], ); - let toolReference = ""; + let bindingReference = ""; let rootBridge: NativeSidecarKernelProxy | null = null; let kernel: Kernel | null = null; let client: SidecarProcess | null = null; let createdNativeVm: CreatedVm | null = null; let nativeSession: AuthenticatedSession | null = null; - let toolShimDir: string | null = null; + let bindingShimDir: string | null = null; let cleanedUp = false; const cleanup = async (): Promise => { @@ -2833,23 +2893,23 @@ export class AgentOs { return; } cleanedUp = true; - if (toolShimDir) { - rmSync(toolShimDir, { recursive: true, force: true }); - toolShimDir = null; + if (bindingShimDir) { + rmSync(bindingShimDir, { recursive: true, force: true }); + bindingShimDir = null; } }; try { const env: Record = getBaseEnvironment(); - if (toolKits && toolKits.length > 0) { - toolShimDir = materializeToolShimDir(toolKits); + if (bindingGroups && bindingGroups.length > 0) { + bindingShimDir = materializeBindingShimDir(bindingGroups); } // Guest command paths. The sidecar owns the `/opt/agentos` projection, // but the client's command map (rpc-client) still needs to KNOW the // projected command names so its shell-exec guard (`this.commands.has("sh")`) // and wasmvm routing work. Seed each projected package's commands from its // `bin` map (mirroring the sidecar projection's command derivation), mapped - // to their projected `/opt/agentos/bin/` path. Tool-shim commands are + // to their projected `/opt/agentos/bin/` path. Binding-shim commands are // added below. const commandGuestPaths = new Map(); const deriveProjectedCommandNames = (dir: string): string[] => { @@ -2894,7 +2954,7 @@ export class AgentOs { const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({ mounts: requestedMounts, - shimDir: toolShimDir, + shimDir: bindingShimDir, }); // Reuse the sidecar handle's single shared native process; this VM // becomes another tenant of it rather than spawning its own process. @@ -2915,7 +2975,7 @@ export class AgentOs { bootstrapLower, ), permissions: sidecarPermissions, - limits: options?.limits, + limits: serializeLimitsForSidecar(options?.limits), loopbackExemptPorts: options?.loopbackExemptPorts ?? [], // 0.3: the Node builtin allow-list moved from configureVm to // VM creation. `undefined` => engine default allow-list; @@ -2966,18 +3026,18 @@ export class AgentOs { packages: sidecarPackages, packagesMountAt: OPT_AGENTOS_ROOT, }); - if (toolKits && toolKits.length > 0) { - toolReference = await registerToolkitsOnSidecar( + if (bindingGroups && bindingGroups.length > 0) { + bindingReference = await registerBindingsOnSidecar( client, session, nativeVm, - toolKits, + bindingGroups, ); commandGuestPaths.set("agentos", "/bin/agentos"); - for (const toolKit of toolKits) { + for (const bindingGroup of bindingGroups) { commandGuestPaths.set( - `agentos-${toolKit.name}`, - `/bin/agentos-${toolKit.name}`, + `agentos-${bindingGroup.name}`, + `/bin/agentos-${bindingGroup.name}`, ); } } @@ -3028,8 +3088,8 @@ export class AgentOs { await snapshotClient.snapshotRootFilesystem(session, nativeVm), ), ), - toolKits: toolKits ?? [], - toolReference, + bindingGroups: bindingGroups ?? [], + bindingReference, async dispose() { if (kernel) { const currentKernel = kernel; @@ -3090,8 +3150,8 @@ export class AgentOs { options?.onLimitWarning, ); vm._sidecarLease = sidecarLease; - vm._toolKits = vmAdmin.toolKits; - vm._toolReference = vmAdmin.toolReference; + vm._bindingGroups = vmAdmin.bindingGroups; + vm._bindingReference = vmAdmin.bindingReference; vm._permissions = vmAdmin.permissions; vm._installSidecarRequestHandler(); vm._cronManager = new CronManager( @@ -3977,8 +4037,8 @@ export class AgentOs { /** Best-effort human label for the tool named in a permission request. */ private _permissionToolLabel(params: Record): string { - if (typeof params.toolName === "string") { - return params.toolName; + if (typeof params.bindingName === "string") { + return params.bindingName; } const toolCall = params.toolCall; if ( @@ -4527,7 +4587,7 @@ export class AgentOs { .map((part) => part?.trim()) .filter((part): part is string => Boolean(part)) .join("\n\n") || undefined, - this._toolReference, + this._bindingReference, ), skipOsInstructions: options?.skipOsInstructions ?? false, }, @@ -4651,8 +4711,8 @@ export class AgentOs { private _installSidecarRequestHandler(): void { const context: HostCallbackContext = { - toolKits: this._toolKits, - toolMap: buildToolMap(this._toolKits), + bindingGroups: this._bindingGroups, + bindingMap: buildBindingMap(this._bindingGroups), permissions: this._permissions, readFile: (path) => this.readFile(path), }; diff --git a/packages/core/src/host-tools-zod.ts b/packages/core/src/host-bindings-zod.ts similarity index 91% rename from packages/core/src/host-tools-zod.ts rename to packages/core/src/host-bindings-zod.ts index 5e4078d4e..c938085a4 100644 --- a/packages/core/src/host-tools-zod.ts +++ b/packages/core/src/host-bindings-zod.ts @@ -21,7 +21,7 @@ const UNSUPPORTED_TYPES = new Set([ type JsonObject = Record; -export class HostToolSchemaConversionError extends Error { +export class BindingSchemaConversionError extends Error { readonly path: string; readonly zodType: string; @@ -34,7 +34,7 @@ export class HostToolSchemaConversionError extends Error { .filter(Boolean) .join(" "), ); - this.name = "HostToolSchemaConversionError"; + this.name = "BindingSchemaConversionError"; this.path = path; this.zodType = zodType; } @@ -130,7 +130,7 @@ function isCustomRefinement(check: unknown): boolean { function validateChecks(schema: ZodType, path: string, typeName: string) { if (getChecks(schema).some(isCustomRefinement)) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "custom refinements cannot be represented faithfully in JSON Schema", @@ -142,7 +142,7 @@ function validateSchema(schema: ZodType, path: string) { const typeName = normalizeTypeName(schema); if (metadataProducesRefs(schema)) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "metadata that emits $ref/$defs is not supported", @@ -150,17 +150,17 @@ function validateSchema(schema: ZodType, path: string) { } if (UNSUPPORTED_TYPES.has(typeName)) { - throw new HostToolSchemaConversionError(path, displayTypeName(typeName)); + throw new BindingSchemaConversionError(path, displayTypeName(typeName)); } if (typeName === "discriminatedunion") { - throw new HostToolSchemaConversionError(path, displayTypeName(typeName)); + throw new BindingSchemaConversionError(path, displayTypeName(typeName)); } if (TRANSPARENT_WRAPPER_TYPES.has(typeName)) { const inner = getInnerSchema(schema); if (!inner) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "wrapper schema is missing its inner schema", @@ -173,7 +173,7 @@ function validateSchema(schema: ZodType, path: string) { if (typeName === "nullable") { const inner = getInnerSchema(schema); if (!inner) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "nullable schema is missing its inner schema", @@ -202,7 +202,7 @@ function validateSchema(schema: ZodType, path: string) { const def = getSchemaDef(schema); const itemSchema = (def.element ?? def.type) as ZodType | undefined; if (!itemSchema) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "array schema is missing its item schema", @@ -217,7 +217,7 @@ function validateSchema(schema: ZodType, path: string) { const keySchema = def.keyType as ZodType | undefined; const valueSchema = def.valueType as ZodType | undefined; if (!keySchema || !valueSchema) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "record schema is missing its key or value schema", @@ -225,7 +225,7 @@ function validateSchema(schema: ZodType, path: string) { } const keyTypeName = normalizeTypeName(keySchema); if (keyTypeName !== "string" || getChecks(keySchema).length > 0) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "record keys must be unconstrained strings", @@ -238,7 +238,7 @@ function validateSchema(schema: ZodType, path: string) { if (typeName === "union") { const options = getSchemaDef(schema).options; if (!Array.isArray(options) || options.length === 0) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "union schema is missing its options", @@ -262,7 +262,7 @@ function validateSchema(schema: ZodType, path: string) { literalValues.length !== 1 || !("string number boolean".split(" ").includes(typeof literalValue) || literalValue === null) ) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( path, displayTypeName(typeName), "literal values must be JSON primitives", @@ -281,7 +281,7 @@ function validateSchema(schema: ZodType, path: string) { return; } - throw new HostToolSchemaConversionError(path, displayTypeName(typeName)); + throw new BindingSchemaConversionError(path, displayTypeName(typeName)); } function sanitizeJsonSchema(value: unknown): unknown { @@ -353,7 +353,7 @@ function generateJsonSchema(schema: ZodType): unknown { target: "jsonSchema7", }); if (!generated || (typeof generated === "object" && Object.keys(generated).length === 0)) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( "$", displayTypeName(normalizeTypeName(schema)), "schema cannot be converted to JSON Schema", @@ -369,7 +369,7 @@ export function zodToJsonSchema(schema: ZodType): unknown { const unsupportedKeyword = findUnsupportedGeneratedKeyword(jsonSchema, "$"); if (unsupportedKeyword) { - throw new HostToolSchemaConversionError( + throw new BindingSchemaConversionError( "$", displayTypeName(normalizeTypeName(schema)), `${unsupportedKeyword.keyword} emitted at ${unsupportedKeyword.path} is not supported`, diff --git a/packages/core/src/host-bindings.ts b/packages/core/src/host-bindings.ts new file mode 100644 index 000000000..1a23732a1 --- /dev/null +++ b/packages/core/src/host-bindings.ts @@ -0,0 +1,107 @@ +import type { ZodType } from "zod"; + +/** Maximum length for binding descriptions (characters). */ +export const MAX_BINDING_DESCRIPTION_LENGTH = 200; + +/** + * A single binding that executes on the host. + * Uses the same typed input/execute pattern as common agent SDK helpers, but + * with host-execution semantics. + */ +export interface Binding { + /** Description shown to the agent in --help and prompt docs. Max 200 characters. */ + description: string; + /** Zod schema for the input. Drives CLI flag generation and validation. */ + inputSchema: ZodType; + /** Runs on the host when the agent invokes the binding. */ + execute: (input: INPUT) => Promise | OUTPUT; + /** Examples included in auto-generated prompt docs. */ + examples?: BindingExample[]; + /** Timeout in ms. Default: 30000. */ + timeout?: number; +} + +export interface BindingExample { + /** Human description of what this example does. */ + description: string; + /** The input args for the example. */ + input: INPUT; +} + +/** + * A named group of bindings. Becomes a CLI binary: agentos-{name}. + */ +export interface BindingGroup { + /** Binding group name. Must be lowercase alphanumeric + hyphens. */ + name: string; + /** Description shown in binding listings and prompt docs. */ + description: string; + /** The bindings in this group. Keys become subcommands. */ + bindings: Record; +} + +export type BindingGroupInput = BindingGroup; + +/** Helper to create a binding with type inference. */ +export function binding( + def: Binding, +): Binding { + return def; +} + +/** Helper to create a binding group. */ +export function bindingGroup(def: BindingGroup): BindingGroup { + return def; +} + +export function normalizeBindingGroup(group: BindingGroupInput): BindingGroup { + return group; +} + +export function normalizeBindingGroups( + groups: BindingGroupInput[], +): BindingGroup[] { + return groups.map(normalizeBindingGroup); +} + +const BINDING_COMMAND_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function validateBindingCommandName( + kind: "Binding group" | "Binding", + name: string, +): void { + if (BINDING_COMMAND_NAME_RE.test(name)) { + return; + } + throw new Error( + `${kind} name "${name}" must be lowercase alphanumeric with optional single hyphen separators`, + ); +} + +/** + * Validate all description lengths in the given binding groups. + * Throws if any group or binding description exceeds MAX_BINDING_DESCRIPTION_LENGTH. + */ +export function validateBindings(bindingGroups: BindingGroupInput[]): void { + for (const input of bindingGroups) { + const group = normalizeBindingGroup(input); + validateBindingCommandName("Binding group", group.name); + if (group.description.length > MAX_BINDING_DESCRIPTION_LENGTH) { + throw new Error( + `Binding group "${group.name}" description is ${group.description.length} characters, max is ${MAX_BINDING_DESCRIPTION_LENGTH}`, + ); + } + for (const [bindingName, bindingDefinition] of Object.entries( + group.bindings, + )) { + validateBindingCommandName("Binding", bindingName); + if ( + bindingDefinition.description.length > MAX_BINDING_DESCRIPTION_LENGTH + ) { + throw new Error( + `Binding "${group.name}/${bindingName}" description is ${bindingDefinition.description.length} characters, max is ${MAX_BINDING_DESCRIPTION_LENGTH}`, + ); + } + } + } +} diff --git a/packages/core/src/host-tools.ts b/packages/core/src/host-tools.ts deleted file mode 100644 index 3222c4797..000000000 --- a/packages/core/src/host-tools.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { ZodType } from "zod"; - -/** Maximum length for tool and toolkit descriptions (characters). */ -export const MAX_TOOL_DESCRIPTION_LENGTH = 200; - -/** - * A single tool that executes on the host. - * Mirrors the shape of AI SDK's tool() but with host-execution semantics. - */ -export interface HostTool { - /** Description shown to the agent in --help and prompt docs. Max 200 characters. */ - description: string; - /** Zod schema for the input. Drives CLI flag generation and validation. */ - inputSchema: ZodType; - /** Runs on the host when the agent invokes the tool. */ - execute: (input: INPUT) => Promise | OUTPUT; - /** Examples included in auto-generated prompt docs. */ - examples?: ToolExample[]; - /** Timeout in ms. Default: 30000. */ - timeout?: number; -} - -export interface ToolExample { - /** Human description of what this example does. */ - description: string; - /** The input args for the example. */ - input: INPUT; -} - -/** - * A named group of tools. Becomes a CLI binary: agentos-{name}. - */ -export interface ToolKit { - /** Toolkit name. Must be lowercase alphanumeric + hyphens. Becomes the CLI suffix: agentos-{name}. */ - name: string; - /** Description shown in `agentos list-tools` and prompt docs. */ - description: string; - /** The tools in this toolkit. Keys become subcommands. */ - tools: Record; -} - -/** Helper to create a HostTool with type inference. */ -export function hostTool( - def: HostTool, -): HostTool { - return def; -} - -/** Helper to create a ToolKit. */ -export function toolKit(def: ToolKit): ToolKit { - return def; -} - -const TOOLKIT_COMMAND_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -function validateToolCommandName(kind: "Toolkit" | "Tool", name: string): void { - if (TOOLKIT_COMMAND_NAME_RE.test(name)) { - return; - } - throw new Error( - `${kind} name "${name}" must be lowercase alphanumeric with optional single hyphen separators`, - ); -} - -/** - * Validate all description lengths in the given toolkits. - * Throws if any toolkit or tool description exceeds MAX_TOOL_DESCRIPTION_LENGTH. - */ -export function validateToolkits(toolKits: ToolKit[]): void { - for (const tk of toolKits) { - validateToolCommandName("Toolkit", tk.name); - if (tk.description.length > MAX_TOOL_DESCRIPTION_LENGTH) { - throw new Error( - `Toolkit "${tk.name}" description is ${tk.description.length} characters, max is ${MAX_TOOL_DESCRIPTION_LENGTH}`, - ); - } - for (const [toolName, tool] of Object.entries(tk.tools)) { - validateToolCommandName("Tool", toolName); - if (tool.description.length > MAX_TOOL_DESCRIPTION_LENGTH) { - throw new Error( - `Tool "${tk.name}/${toolName}" description is ${tool.description.length} characters, max is ${MAX_TOOL_DESCRIPTION_LENGTH}`, - ); - } - } - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ad9e963c..3be51aed8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,16 +10,19 @@ export { } from "./cron/index.js"; export { createHostDirBackend, nodeModulesMount } from "./host-dir-mount.js"; export { - hostTool, - MAX_TOOL_DESCRIPTION_LENGTH, - toolKit, - validateToolkits, -} from "./host-tools.js"; + binding, + bindingGroup, + MAX_BINDING_DESCRIPTION_LENGTH, + normalizeBindingGroup, + normalizeBindingGroups, + validateBindings, +} from "./host-bindings.js"; export { agentOsLimitsSchema, agentOsOptionFieldSchemas, agentOsOptionsSchema, - hostToolSchema, + bindingGroupSchema, + bindingSchema, mountConfigSchema, nativeMountConfigSchema, parseAgentOsOptions, @@ -27,7 +30,6 @@ export { rootFilesystemConfigSchema, sharedSidecarConfigSchema, sidecarConfigSchema, - toolKitSchema, } from "./options-schema.js"; export { createInMemoryLayerStore, diff --git a/packages/core/src/options-schema.ts b/packages/core/src/options-schema.ts index 0bdd4a3f6..b156378c0 100644 --- a/packages/core/src/options-schema.ts +++ b/packages/core/src/options-schema.ts @@ -6,7 +6,11 @@ import type { LimitWarningHandler, NativeMountConfig, } from "./agent-os.js"; -import type { HostTool, ToolKit } from "./host-tools.js"; +import type { + Binding, + BindingGroup, + BindingGroupInput, +} from "./host-bindings.js"; const stringArray = z.array(z.string()); const nonNegativeInteger = z.number().int().nonnegative(); @@ -96,16 +100,16 @@ export const agentOsLimitsSchema = z .object({ maxFetchResponseBytes: nonNegativeInteger.optional() }) .strict() .optional(), - tools: z + bindings: z .object({ - defaultToolTimeoutMs: nonNegativeInteger.optional(), - maxToolTimeoutMs: nonNegativeInteger.optional(), - maxRegisteredToolkits: nonNegativeInteger.optional(), - maxRegisteredToolsPerVm: nonNegativeInteger.optional(), - maxToolsPerToolkit: nonNegativeInteger.optional(), - maxToolSchemaBytes: nonNegativeInteger.optional(), - maxToolExamplesPerTool: nonNegativeInteger.optional(), - maxToolExampleInputBytes: nonNegativeInteger.optional(), + defaultBindingTimeoutMs: nonNegativeInteger.optional(), + maxBindingTimeoutMs: nonNegativeInteger.optional(), + maxRegisteredBindingGroups: nonNegativeInteger.optional(), + maxRegisteredBindingsPerVm: nonNegativeInteger.optional(), + maxBindingsPerGroup: nonNegativeInteger.optional(), + maxBindingSchemaBytes: nonNegativeInteger.optional(), + maxBindingExamplesPerBinding: nonNegativeInteger.optional(), + maxBindingExampleInputBytes: nonNegativeInteger.optional(), }) .strict() .optional(), @@ -230,32 +234,35 @@ export const sidecarConfigSchema = z.union([ explicitSidecarSchema, ]); -const toolExampleSchema = z +const bindingExampleSchema = z .object({ description: z.string(), input: z.unknown(), }) .strict(); -export const hostToolSchema = z +export const bindingSchema = z .object({ description: z.string(), inputSchema: z.custom((value) => typeof value === "object" && value !== null, { message: "Expected Zod schema object", }), execute: functionSchema, - examples: z.array(toolExampleSchema).optional(), + examples: z.array(bindingExampleSchema).optional(), timeout: nonNegativeInteger.optional(), }) - .strict() as z.ZodType; + .strict() as z.ZodType; -export const toolKitSchema = z +export const bindingGroupSchema = z .object({ name: z.string(), description: z.string(), - tools: z.record(z.string(), hostToolSchema), + bindings: z.record(z.string(), bindingSchema), }) - .strict() as z.ZodType; + .strict() as z.ZodType; + +const bindingGroupInputSchema = + bindingGroupSchema as z.ZodType; /** * Shared AgentOsOptions field schemas. @@ -280,7 +287,7 @@ export const agentOsOptionFieldSchemas = { message: "Expected schedule driver object", }) .optional(), - toolKits: z.array(toolKitSchema).optional(), + bindings: z.array(bindingGroupInputSchema).optional(), permissions: permissionsSchema.optional(), sidecar: sidecarConfigSchema.optional(), limits: agentOsLimitsSchema.optional(), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2c29184ce..aaaa951b0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -66,7 +66,12 @@ export type { HostDirMountPluginConfig, NodeModulesMountConfig, } from "./host-dir-mount.js"; -export type { HostTool, ToolExample, ToolKit } from "./host-tools.js"; +export type { + Binding, + BindingExample, + BindingGroup, + BindingGroupInput, +} from "./host-bindings.js"; export type { AcpTimeoutErrorData, JsonRpcError, diff --git a/packages/core/tests/toolkit-permissions.test.ts b/packages/core/tests/binding-permissions.test.ts similarity index 84% rename from packages/core/tests/toolkit-permissions.test.ts rename to packages/core/tests/binding-permissions.test.ts index 03f829f8e..a0cd2cf9b 100644 --- a/packages/core/tests/toolkit-permissions.test.ts +++ b/packages/core/tests/binding-permissions.test.ts @@ -1,7 +1,7 @@ import common from "@agentos-software/common"; import { afterEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import { AgentOs, hostTool, toolKit } from "../src/index.js"; +import { AgentOs, binding, bindingGroup } from "../src/index.js"; import { NativeSidecarProcessClient } from "../src/sidecar/rpc-client.js"; // --------------------------------------------------------------------------- @@ -9,11 +9,11 @@ import { NativeSidecarProcessClient } from "../src/sidecar/rpc-client.js"; // // THREAT MODEL: untrusted guest / agent code can emit a raw `host_callback` // sidecar-request frame whose `callback_key` and `input` it fully controls. -// The CLI command path (`agentos- `) is gated by `toolPermissionMode` -// via `invokeHostTool`, but the raw `host_callback` RPC path is handled by +// The CLI command path (`agentos- `) is gated by `toolPermissionMode` +// via `invokeBinding`, but the raw `host_callback` RPC path is handled by // `handleHostCallback` in agent-os.ts. These tests play the guest and assert // the system DENIES the call (execute must never run) when policy denies or the -// tool is out of the granted pattern scope. +// binding is out of the granted pattern scope. // // We capture the real `SidecarRequestHandler` that `AgentOs.create()` installs // on the native sidecar client (via a prototype spy), then feed it forged @@ -70,7 +70,7 @@ function hostCallbackFrame(callbackKey: string, input: unknown) { // A forged *command-shaped* host_callback. `handleHostCallback` dispatches any // input that parses as `{type:'command',command,args,cwd}` through the SECOND // branch (`handleHostCommandCallback` -> `handleAgentOsToolkitCommand` -> -// `invokeHostTool`), bypassing the `callback_key`/Zod path entirely. We forge a +// `invokeBinding`), bypassing the `callback_key`/Zod path entirely. We forge a // CLI-style command frame to confirm THAT branch also enforces binding.invoke. function commandHostCallbackFrame(command: string, args: string[]) { return { @@ -79,7 +79,7 @@ function commandHostCallbackFrame(command: string, args: string[]) { payload: { type: "host_callback" as const, invocation_id: "guest-forged-cmd-1", - // callback_key is irrelevant on the command branch; set it to a tool + // callback_key is irrelevant on the command branch; set it to a binding // that DOES exist to prove the command branch is what runs. callback_key: "math:add", input: { @@ -93,11 +93,11 @@ function commandHostCallbackFrame(command: string, args: string[]) { }; } -const mathToolKit = toolKit({ +const mathBindingGroup = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), @@ -108,11 +108,11 @@ const mathToolKit = toolKit({ }, }); -const duplicateMathToolKit = toolKit({ +const duplicateMathBindingGroup = bindingGroup({ name: "math", description: "Duplicate math utilities", - tools: { - multiply: hostTool({ + bindings: { + multiply: binding({ description: "Multiply two numbers", inputSchema: z.object({ a: z.number(), @@ -142,7 +142,7 @@ async function runCommand(vm: AgentOs, command: string, args: string[]) { }; } -describe("toolkit permissions", () => { +describe("binding group permissions", () => { let vm: AgentOs | null = null; afterEach(async () => { @@ -150,18 +150,18 @@ describe("toolkit permissions", () => { vm = null; }); - test("rejects duplicate toolkit registration with a conflict", async () => { + test("rejects duplicate binding group registration with a conflict", async () => { await expect( AgentOs.create({ - toolKits: [mathToolKit, duplicateMathToolKit], + bindingGroups: [mathBindingGroup, duplicateMathBindingGroup], }), - ).rejects.toThrow(/conflict: toolkit already registered: math/); + ).rejects.toThrow(/conflict: binding group already registered: math/); }); - test("allows toolkit invocation with default permissions", async () => { + test("allows binding group invocation with default permissions", async () => { vm = await AgentOs.create({ software: [common], - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], }); const result = await runCommand(vm, "agentos-math", [ @@ -178,10 +178,10 @@ describe("toolkit permissions", () => { }); }); - test("denies toolkit invocation by default until tool permissions are granted", async () => { + test("denies binding group invocation by default until binding permissions are granted", async () => { vm = await AgentOs.create({ software: [common], - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], permissions: { fs: "allow", childProcess: "allow", @@ -201,10 +201,10 @@ describe("toolkit permissions", () => { expect(result.stderr).toContain("math:add"); }); - test("allows toolkit invocation when a matching tool permission is granted", async () => { + test("allows binding group invocation when a matching binding permission is granted", async () => { vm = await AgentOs.create({ software: [common], - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], permissions: { fs: "allow", childProcess: "allow", @@ -236,7 +236,7 @@ describe("toolkit permissions", () => { }); }); -describe("toolkit permissions — raw host_callback RPC path", () => { +describe("binding group permissions — raw host_callback RPC path", () => { let vm: AgentOs | null = null; afterEach(async () => { @@ -245,13 +245,13 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); // N-001 (J.1/J.2): host_callback RPC must honor binding.invoke deny. - test("denies host_callback RPC tool invocation when binding.invoke policy is deny (not just the CLI path)", async () => { + test("denies host_callback RPC binding invocation when binding.invoke policy is deny (not just the CLI path)", async () => { const executed: unknown[] = []; - const spyKit = toolKit({ + const spyKit = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), execute: ({ a, b }) => { @@ -266,7 +266,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { // No `software` needed: this exercises the raw host_callback RPC // handler directly (the guest-controlled path), which does not spawn // any in-VM CLI. Keeping the VM minimal makes the safeguard fast. - toolKits: [spyKit], + bindingGroups: [spyKit], permissions: { fs: "allow", childProcess: "allow", @@ -286,17 +286,17 @@ describe("toolkit permissions — raw host_callback RPC path", () => { expect(response.type).toBe("host_callback_result"); expect(response.result).toBeUndefined(); expect(typeof response.error).toBe("string"); - expect(response.error).toMatch(/tool\.invoke|EACCES|denied|permission/i); + expect(response.error).toMatch(/binding\.invoke|EACCES|denied|permission/i); }); // N-002 (J.2): host_callback RPC must respect binding.invoke pattern scope. - test("host_callback RPC respects binding.invoke pattern scope and denies a non-matching tool", async () => { + test("host_callback RPC respects binding.invoke pattern scope and denies a non-matching binding", async () => { const executed: string[] = []; - const dangerKit = toolKit({ + const dangerKit = bindingGroup({ name: "math", - description: "Math utilities with a dangerous tool", - tools: { - safe: hostTool({ + description: "Math utilities with a dangerous binding", + bindings: { + safe: binding({ description: "Safe op", inputSchema: z.object({ x: z.number() }), execute: ({ x }) => { @@ -304,7 +304,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { return { x }; }, }), - danger: hostTool({ + danger: binding({ description: "Dangerous op", inputSchema: z.object({ x: z.number() }), execute: ({ x }) => { @@ -316,7 +316,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); const created = await createVmCapturingHandler({ - toolKits: [dangerKit], + bindingGroups: [dangerKit], permissions: { fs: "allow", childProcess: "allow", @@ -339,24 +339,24 @@ describe("toolkit permissions — raw host_callback RPC path", () => { expect(response.type).toBe("host_callback_result"); expect(response.result).toBeUndefined(); expect(typeof response.error).toBe("string"); - expect(response.error).toMatch(/tool\.invoke|EACCES|denied|permission/i); + expect(response.error).toMatch(/binding\.invoke|EACCES|denied|permission/i); }); // AOSFS-1 (P1, J.1/J.2): the raw host_callback RPC path is fully // guest-controlled, including the `input` object. The guest can stuff extra // keys, a `__proto__` payload, and a `constructor` key into `input` to try to // (a) leak raw unvalidated fields into the host-side `execute`, or (b) pollute - // Object.prototype on the host. The handler runs `tool.inputSchema.safeParse` + // Object.prototype on the host. The handler runs `binding.inputSchema.safeParse` // and passes ONLY `parsed.data` to execute; a strict/stripping Zod object must // hand `execute` exactly the declared keys and nothing else, and no prototype // pollution may occur. Asserts the system strips the hostile/extra keys. test("host_callback strips hostile/extra input keys; execute receives only validated Zod data and no prototype pollution", async () => { const seen: unknown[] = []; - const kit = toolKit({ + const kit = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), execute: (input) => { @@ -370,7 +370,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); const created = await createVmCapturingHandler({ - toolKits: [kit], + bindingGroups: [kit], permissions: { fs: "allow", childProcess: "allow", @@ -395,7 +395,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { hostCallbackFrame("math:add", hostileInput), ); - // The tool ran (policy allows math:add) and produced the correct result. + // The binding ran (policy allows math:add) and produced the correct result. expect(response.type).toBe("host_callback_result"); expect(response.error).toBeUndefined(); expect(response.result).toEqual({ sum: 5 }); @@ -418,16 +418,16 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); // AOSFS-2 (P2): a guest can send schema-failing input on the raw host_callback - // RPC path (which does NOT go through the CLI argv parser / sidecar-tool - // dispatch validation at sidecar-tool-dispatch:108). The handler must + // RPC path (which does NOT go through the CLI argv parser / sidecar-binding + // dispatch validation at sidecar-binding-dispatch:108). The handler must // safeParse and return a validation error WITHOUT invoking execute. test("host_callback rejects schema-failing input without invoking execute", async () => { const executed: unknown[] = []; - const kit = toolKit({ + const kit = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), execute: ({ a, b }) => { @@ -439,7 +439,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); const created = await createVmCapturingHandler({ - toolKits: [kit], + bindingGroups: [kit], permissions: { fs: "allow", childProcess: "allow", @@ -467,17 +467,17 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); // AOS-SESS-4 (N-014, P2, J.1/J.2): the *command-shaped* host_callback dispatch - // branch (handleHostCommandCallback -> invokeHostTool) must ALSO honor + // branch (handleHostCommandCallback -> invokeBinding) must ALSO honor // binding.invoke deny — defense-in-depth on the second dispatch path that the // callback_key/Zod branch does not cover. (Hold-as-regression; not a // re-discovery — assert the gate holds on this branch.) test("forged {type:'command'} host_callback is denied by binding.invoke on the command dispatch branch", async () => { const executed: unknown[] = []; - const spyKit = toolKit({ + const spyKit = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), execute: ({ a, b }) => { @@ -489,7 +489,7 @@ describe("toolkit permissions — raw host_callback RPC path", () => { }); const created = await createVmCapturingHandler({ - toolKits: [spyKit], + bindingGroups: [spyKit], permissions: { fs: "allow", childProcess: "allow", @@ -510,6 +510,6 @@ describe("toolkit permissions — raw host_callback RPC path", () => { expect(response.type).toBe("host_callback_result"); expect(response.result).toBeUndefined(); expect(typeof response.error).toBe("string"); - expect(response.error).toMatch(/tool\.invoke|EACCES|denied|permission/i); + expect(response.error).toMatch(/binding\.invoke|EACCES|denied|permission/i); }); }); diff --git a/packages/core/tests/tool-reference.test.ts b/packages/core/tests/binding-reference.test.ts similarity index 84% rename from packages/core/tests/tool-reference.test.ts rename to packages/core/tests/binding-reference.test.ts index 0f305b675..fa722276d 100644 --- a/packages/core/tests/tool-reference.test.ts +++ b/packages/core/tests/binding-reference.test.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { z } from "zod"; -import { AgentOs, hostTool, toolKit } from "../src/index.js"; +import { AgentOs, binding, bindingGroup } from "../src/index.js"; import type { AgentConfig } from "../src/agents.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); @@ -47,11 +47,11 @@ process.stdin.on('data', (chunk) => { }); `; -const mathToolKit = toolKit({ +const mathBindingGroup = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), @@ -68,13 +68,13 @@ const mathToolKit = toolKit({ }, }); -describe("tool reference registration", () => { +describe("binding reference registration", () => { let vm: AgentOs; beforeEach(async () => { vm = await AgentOs.create({ mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], }); }); @@ -98,13 +98,13 @@ describe("tool reference registration", () => { }; } - test("stores generated tool reference markdown on the VM", () => { + test("stores generated binding reference markdown on the VM", () => { const toolReference = (vm as unknown as { _toolReference: string }) ._toolReference; - expect(toolReference).toContain("## Available Host Tools"); + expect(toolReference).toContain("## Available Host Bindings"); expect(toolReference).toContain( - "Run `agentos list-tools` to see all available tools.", + "Run `agentos list-bindings` to see all available bindings.", ); expect(toolReference).toContain("### math"); expect(toolReference).toContain("Math utilities"); @@ -114,8 +114,8 @@ describe("tool reference registration", () => { expect(toolReference).toContain("Add 1 and 2"); }); - test("createSession injects the registered tool reference into the system prompt", async () => { - const scriptPath = "/tmp/mock-tool-reference-adapter.mjs"; + test("createSession injects the registered binding reference into the system prompt", async () => { + const scriptPath = "/tmp/mock-binding-reference-adapter.mjs"; await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); const restore = useMockAdapterBin(scriptPath); @@ -129,7 +129,7 @@ describe("tool reference registration", () => { const argIndex = argv.indexOf("--append-system-prompt"); expect(argIndex).toBeGreaterThan(-1); const prompt = argv[argIndex + 1]; - expect(prompt).toContain("## Available Host Tools"); + expect(prompt).toContain("## Available Host Bindings"); expect(prompt).toContain("`agentos-math add --a --b `"); expect(prompt).toContain("### math"); diff --git a/packages/core/tests/host-tools-zod.test.ts b/packages/core/tests/host-bindings-zod.test.ts similarity index 96% rename from packages/core/tests/host-tools-zod.test.ts rename to packages/core/tests/host-bindings-zod.test.ts index e9580dad5..0b5d60a65 100644 --- a/packages/core/tests/host-tools-zod.test.ts +++ b/packages/core/tests/host-bindings-zod.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { z as z3 } from "zod3"; import { - HostToolSchemaConversionError, + BindingSchemaConversionError, zodToJsonSchema, -} from "../src/host-tools-zod.js"; +} from "../src/host-bindings-zod.js"; describe("zodToJsonSchema", () => { test("converts objects with supported scalar constraints", () => { @@ -151,7 +151,7 @@ describe("zodToJsonSchema", () => { }); expect(() => zodToJsonSchema(schema)).toThrowError( - HostToolSchemaConversionError, + BindingSchemaConversionError, ); expect(() => zodToJsonSchema(schema)).toThrowError( /Unsupported Zod schema at \$\.payload: discriminatedUnion/, @@ -208,7 +208,7 @@ describe("zodToJsonSchema", () => { zodToJsonSchema(testCase.schema); throw new Error(`Expected ${testCase.type} to fail`); } catch (error) { - expect(error).toBeInstanceOf(HostToolSchemaConversionError); + expect(error).toBeInstanceOf(BindingSchemaConversionError); expect(error).toMatchObject({ path: testCase.path, zodType: testCase.type, diff --git a/packages/core/tests/host-bindings.test.ts b/packages/core/tests/host-bindings.test.ts new file mode 100644 index 000000000..706264581 --- /dev/null +++ b/packages/core/tests/host-bindings.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { + BindingSchemaConversionError, + zodToJsonSchema, +} from "../src/host-bindings-zod.js"; +import { + MAX_BINDING_DESCRIPTION_LENGTH, + binding, + bindingGroup, + validateBindings, +} from "../src/index.js"; + +describe("host binding description limits", () => { + test("accepts binding group and binding descriptions at the exported limit", () => { + const description = "a".repeat(MAX_BINDING_DESCRIPTION_LENGTH); + + expect(() => + validateBindings([ + bindingGroup({ + name: "browser", + description, + bindings: { + screenshot: binding({ + description, + inputSchema: z.object({ url: z.string() }), + execute: () => ({ ok: true }), + }), + }, + }), + ]), + ).not.toThrow(); + }); + + test("rejects binding group descriptions longer than the exported limit", () => { + expect(() => + validateBindings([ + bindingGroup({ + name: "browser", + description: "a".repeat(MAX_BINDING_DESCRIPTION_LENGTH + 1), + bindings: { + screenshot: binding({ + description: "Take a screenshot", + inputSchema: z.object({ url: z.string() }), + execute: () => ({ ok: true }), + }), + }, + }), + ]), + ).toThrow( + `Binding group "browser" description is ${MAX_BINDING_DESCRIPTION_LENGTH + 1} characters, max is ${MAX_BINDING_DESCRIPTION_LENGTH}`, + ); + }); + + test("rejects binding descriptions longer than the exported limit", () => { + expect(() => + validateBindings([ + bindingGroup({ + name: "browser", + description: "Browser automation", + bindings: { + screenshot: binding({ + description: "a".repeat(MAX_BINDING_DESCRIPTION_LENGTH + 1), + inputSchema: z.object({ url: z.string() }), + execute: () => ({ ok: true }), + }), + }, + }), + ]), + ).toThrow( + `Binding "browser/screenshot" description is ${MAX_BINDING_DESCRIPTION_LENGTH + 1} characters, max is ${MAX_BINDING_DESCRIPTION_LENGTH}`, + ); + }); + + test("rejects binding group names that cannot become stable command names", () => { + expect(() => + validateBindings([ + bindingGroup({ + name: "Browser_Tools", + description: "Browser automation", + bindings: { + screenshot: binding({ + description: "Take a screenshot", + inputSchema: z.object({ url: z.string() }), + execute: () => ({ ok: true }), + }), + }, + }), + ]), + ).toThrow( + 'Binding group name "Browser_Tools" must be lowercase alphanumeric with optional single hyphen separators', + ); + }); + + test("rejects binding names that cannot become stable subcommands", () => { + expect(() => + validateBindings([ + bindingGroup({ + name: "browser-bindings", + description: "Browser automation", + bindings: { + "screenshot_now": binding({ + description: "Take a screenshot", + inputSchema: z.object({ url: z.string() }), + execute: () => ({ ok: true }), + }), + }, + }), + ]), + ).toThrow( + 'Binding name "screenshot_now" must be lowercase alphanumeric with optional single hyphen separators', + ); + }); + + test("fails loudly when a binding input schema uses an unsupported discriminated union", () => { + const hostBinding = binding({ + description: "Inspect a variant payload", + inputSchema: z.object({ + payload: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("text"), value: z.string() }), + z.object({ kind: z.literal("code"), status: z.number() }), + ]), + }), + execute: () => ({ ok: true }), + }); + + try { + zodToJsonSchema(hostBinding.inputSchema); + throw new Error("Expected unsupported binding schema to fail"); + } catch (error) { + expect(error).toBeInstanceOf(BindingSchemaConversionError); + expect(error).toMatchObject({ + path: "$.payload", + zodType: "discriminatedUnion", + }); + } + }); +}); diff --git a/packages/core/tests/host-tools.test.ts b/packages/core/tests/host-tools.test.ts deleted file mode 100644 index 624234908..000000000 --- a/packages/core/tests/host-tools.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { z } from "zod"; -import { - HostToolSchemaConversionError, - zodToJsonSchema, -} from "../src/host-tools-zod.js"; -import { - MAX_TOOL_DESCRIPTION_LENGTH, - hostTool, - toolKit, - validateToolkits, -} from "../src/index.js"; - -describe("host tool description limits", () => { - test("accepts toolkit and tool descriptions at the exported limit", () => { - const description = "a".repeat(MAX_TOOL_DESCRIPTION_LENGTH); - - expect(() => - validateToolkits([ - toolKit({ - name: "browser", - description, - tools: { - screenshot: hostTool({ - description, - inputSchema: z.object({ url: z.string() }), - execute: () => ({ ok: true }), - }), - }, - }), - ]), - ).not.toThrow(); - }); - - test("rejects toolkit descriptions longer than the exported limit", () => { - expect(() => - validateToolkits([ - toolKit({ - name: "browser", - description: "a".repeat(MAX_TOOL_DESCRIPTION_LENGTH + 1), - tools: { - screenshot: hostTool({ - description: "Take a screenshot", - inputSchema: z.object({ url: z.string() }), - execute: () => ({ ok: true }), - }), - }, - }), - ]), - ).toThrow( - `Toolkit "browser" description is ${MAX_TOOL_DESCRIPTION_LENGTH + 1} characters, max is ${MAX_TOOL_DESCRIPTION_LENGTH}`, - ); - }); - - test("rejects tool descriptions longer than the exported limit", () => { - expect(() => - validateToolkits([ - toolKit({ - name: "browser", - description: "Browser automation", - tools: { - screenshot: hostTool({ - description: "a".repeat(MAX_TOOL_DESCRIPTION_LENGTH + 1), - inputSchema: z.object({ url: z.string() }), - execute: () => ({ ok: true }), - }), - }, - }), - ]), - ).toThrow( - `Tool "browser/screenshot" description is ${MAX_TOOL_DESCRIPTION_LENGTH + 1} characters, max is ${MAX_TOOL_DESCRIPTION_LENGTH}`, - ); - }); - - test("rejects toolkit names that cannot become stable command names", () => { - expect(() => - validateToolkits([ - toolKit({ - name: "Browser_Tools", - description: "Browser automation", - tools: { - screenshot: hostTool({ - description: "Take a screenshot", - inputSchema: z.object({ url: z.string() }), - execute: () => ({ ok: true }), - }), - }, - }), - ]), - ).toThrow( - 'Toolkit name "Browser_Tools" must be lowercase alphanumeric with optional single hyphen separators', - ); - }); - - test("rejects tool names that cannot become stable subcommands", () => { - expect(() => - validateToolkits([ - toolKit({ - name: "browser-tools", - description: "Browser automation", - tools: { - "screenshot_now": hostTool({ - description: "Take a screenshot", - inputSchema: z.object({ url: z.string() }), - execute: () => ({ ok: true }), - }), - }, - }), - ]), - ).toThrow( - 'Tool name "screenshot_now" must be lowercase alphanumeric with optional single hyphen separators', - ); - }); - - test("fails loudly when a host tool input schema uses an unsupported discriminated union", () => { - const tool = hostTool({ - description: "Inspect a variant payload", - inputSchema: z.object({ - payload: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("text"), value: z.string() }), - z.object({ kind: z.literal("code"), status: z.number() }), - ]), - }), - execute: () => ({ ok: true }), - }); - - try { - zodToJsonSchema(tool.inputSchema); - throw new Error("Expected unsupported host tool schema to fail"); - } catch (error) { - expect(error).toBeInstanceOf(HostToolSchemaConversionError); - expect(error).toMatchObject({ - path: "$.payload", - zodType: "discriminatedUnion", - }); - } - }); -}); diff --git a/packages/core/tests/migration-parity.test.ts b/packages/core/tests/migration-parity.test.ts index ff5d29123..f8253531b 100644 --- a/packages/core/tests/migration-parity.test.ts +++ b/packages/core/tests/migration-parity.test.ts @@ -4,7 +4,7 @@ import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agentos-software/common"; import { afterEach, describe, expect, test } from "vitest"; import { z } from "zod"; -import { AgentOs, hostTool, toolKit } from "../src/index.js"; +import { AgentOs, binding, bindingGroup } from "../src/index.js"; import type { AgentConfig } from "../src/agents.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); @@ -102,7 +102,7 @@ process.stdin.on("data", (chunk) => { sessionId: "mock-session-1", update: { sessionUpdate: "tool_call", - title: "synthetic-tool", + title: "synthetic-binding", status: "completed", }, }, @@ -139,11 +139,11 @@ process.stdin.on("data", (chunk) => { }); `.trim(); -const mathToolKit = toolKit({ +const mathBindingGroup = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), @@ -274,10 +274,10 @@ describe("native sidecar migration parity gate", () => { ).toBe("filesystem-ok"); }, 60_000); - test("covers registered host tools through guest command dispatch on the Rust sidecar path", async () => { + test("covers registered host bindings through guest command dispatch on the Rust sidecar path", async () => { const vm = await AgentOs.create({ software: [common], - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], permissions: { fs: "allow", childProcess: "allow", @@ -289,16 +289,16 @@ describe("native sidecar migration parity gate", () => { }); assertNativeSidecar(vm); - const listed = await runSpawnedProcess(vm, "agentos", ["list-tools"]); + const listed = await runSpawnedProcess(vm, "agentos", ["list-bindings"]); expect(listed.exitCode).toBe(0); expect(JSON.parse(listed.stdout)).toEqual({ ok: true, result: { - toolkits: [ + bindingGroups: [ { name: "math", description: "Math utilities", - tools: ["add"], + bindings: ["add"], }, ], }, diff --git a/packages/core/tests/options-schema.test.ts b/packages/core/tests/options-schema.test.ts index e2fbe8421..ce3bd563f 100644 --- a/packages/core/tests/options-schema.test.ts +++ b/packages/core/tests/options-schema.test.ts @@ -19,4 +19,26 @@ describe("AgentOsOptions validation", () => { }), ).toThrow(/filesystem/); }); + + test("rejects create option factories on the one-shot core constructor", () => { + expect(() => + agentOsOptionsSchema.parse({ + createOptions: () => ({}), + }), + ).toThrow(/createOptions/); + }); + + test("accepts bindings as the public name for host binding groups", () => { + expect( + agentOsOptionsSchema.safeParse({ + bindings: [ + { + name: "weather", + description: "Weather bindings", + bindings: {}, + }, + ], + }).success, + ).toBe(true); + }); }); diff --git a/packages/core/tests/public-api-exports.test.ts b/packages/core/tests/public-api-exports.test.ts index 754ecdabb..eb147b481 100644 --- a/packages/core/tests/public-api-exports.test.ts +++ b/packages/core/tests/public-api-exports.test.ts @@ -5,12 +5,15 @@ import { AgentOsSidecar, CronManager, KernelError, - MAX_TOOL_DESCRIPTION_LENGTH, + MAX_BINDING_DESCRIPTION_LENGTH, InvalidScheduleError, PastScheduleError, TimerScheduleDriver, agentOsLimitsSchema, agentOsOptionsSchema, + binding, + bindingGroup, + bindingGroupSchema, createHostDirBackend, createInMemoryFileSystem, createInMemoryLayerStore, @@ -19,17 +22,13 @@ import { isPackageDescriptor, OPT_AGENTOS_BIN, OPT_AGENTOS_ROOT, - hostTool, - hostToolSchema, isAcpTimeoutErrorData, isUnknownSessionErrorData, mountConfigSchema, nodeModulesMount, parseAgentOsOptions, rootFilesystemConfigSchema, - toolKit, - toolKitSchema, - validateToolkits, + validateBindings, type AcpTimeoutErrorData, type AgentOsLimits, type ExecOptions, @@ -58,16 +57,15 @@ describe("root public API exports", () => { expect(CronManager).toBeTypeOf("function"); expect(TimerScheduleDriver).toBeTypeOf("function"); expect(createHostDirBackend).toBeTypeOf("function"); - expect(hostTool).toBeTypeOf("function"); - expect(toolKit).toBeTypeOf("function"); - expect(validateToolkits).toBeTypeOf("function"); - expect(MAX_TOOL_DESCRIPTION_LENGTH).toBeGreaterThan(0); + expect(binding).toBeTypeOf("function"); + expect(bindingGroup).toBeTypeOf("function"); + expect(validateBindings).toBeTypeOf("function"); + expect(MAX_BINDING_DESCRIPTION_LENGTH).toBeGreaterThan(0); expect(agentOsLimitsSchema.safeParse({}).success).toBe(true); expect(agentOsOptionsSchema.safeParse({ defaultSoftware: false }).success).toBe( true, ); - expect(hostToolSchema).toBeTypeOf("object"); - expect(toolKitSchema).toBeTypeOf("object"); + expect(bindingGroupSchema).toBeTypeOf("object"); expect(mountConfigSchema).toBeTypeOf("object"); expect(rootFilesystemConfigSchema).toBeTypeOf("object"); expect(parseAgentOsOptions({ defaultSoftware: false })).toEqual({ diff --git a/packages/core/tests/sandbox-integration.test.ts b/packages/core/tests/sandbox-integration.test.ts index 58f9d6455..e1ec1f5cb 100644 --- a/packages/core/tests/sandbox-integration.test.ts +++ b/packages/core/tests/sandbox-integration.test.ts @@ -1,7 +1,7 @@ import common from "@agentos-software/common"; import { + createSandboxBindings, createSandboxFs, - createSandboxToolkit, } from "@rivet-dev/agentos-sandbox"; import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest"; import { AgentOs } from "../src/index.js"; @@ -46,7 +46,7 @@ describe("sandbox quickstart truth test", () => { } }); - test("mounts createSandboxFs and exercises run-command plus list-processes from createSandboxToolkit", async () => { + test("mounts createSandboxFs and exercises sandbox bindings", async () => { if (!sandbox) { throw new Error("Sandbox test harness did not start."); } @@ -60,7 +60,7 @@ describe("sandbox quickstart truth test", () => { plugin: createSandboxFs({ client: sandbox.client }), }, ], - toolKits: [createSandboxToolkit({ client: sandbox.client })], + bindings: [createSandboxBindings({ client: sandbox.client })], }); await sandbox.client.writeFsFile( @@ -70,8 +70,10 @@ describe("sandbox quickstart truth test", () => { const content = await vm.readFile(SANDBOX_FILE_PATH); expect(new TextDecoder().decode(content)).toBe(SANDBOX_FILE_CONTENT); - const toolkit = createSandboxToolkit({ client: sandbox.client }); - const runCommandResponse = (await toolkit.tools["run-command"].execute({ + const sandboxBindings = createSandboxBindings({ client: sandbox.client }); + const runCommandResponse = (await sandboxBindings.bindings[ + "run-command" + ].execute({ command: "echo", args: ["hello from sandbox"], })) as { @@ -83,7 +85,9 @@ describe("sandbox quickstart truth test", () => { expect(runCommandResponse.stderr).toBe(""); expect(runCommandResponse.stdout.trim()).toBe("hello from sandbox"); - const createdProcess = (await toolkit.tools["create-process"].execute({ + const createdProcess = (await sandboxBindings.bindings[ + "create-process" + ].execute({ command: "sleep", args: ["60"], })) as { @@ -92,7 +96,7 @@ describe("sandbox quickstart truth test", () => { }; expect(createdProcess.status).toBe("running"); - const listProcessesResponse = (await toolkit.tools[ + const listProcessesResponse = (await sandboxBindings.bindings[ "list-processes" ].execute({})) as { processes: Array<{ @@ -110,6 +114,8 @@ describe("sandbox quickstart truth test", () => { ), ).toBe(true); - await toolkit.tools["kill-process"].execute({ id: createdProcess.id }); + await sandboxBindings.bindings["kill-process"].execute({ + id: createdProcess.id, + }); }, 150_000); }); diff --git a/packages/core/tests/sidecar-tool-dispatch.test.ts b/packages/core/tests/sidecar-binding-dispatch.test.ts similarity index 74% rename from packages/core/tests/sidecar-tool-dispatch.test.ts rename to packages/core/tests/sidecar-binding-dispatch.test.ts index 23694ee84..17017c3a6 100644 --- a/packages/core/tests/sidecar-tool-dispatch.test.ts +++ b/packages/core/tests/sidecar-binding-dispatch.test.ts @@ -1,14 +1,14 @@ import common from "@agentos-software/common"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { z } from "zod"; -import { AgentOs, hostTool, toolKit } from "../src/index.js"; +import { AgentOs, binding, bindingGroup } from "../src/index.js"; import { ALLOW_ALL_VM_PERMISSIONS } from "./helpers/permissions.js"; -const mathToolKit = toolKit({ +const mathBindingGroup = bindingGroup({ name: "math", description: "Math utilities", - tools: { - add: hostTool({ + bindings: { + add: binding({ description: "Add two numbers", inputSchema: z.object({ a: z.number(), @@ -38,13 +38,13 @@ async function runCommand(vm: AgentOs, command: string, args: string[]) { }; } -describe("native sidecar tool dispatch", () => { +describe("native sidecar binding dispatch", () => { let vm: AgentOs; beforeEach(async () => { vm = await AgentOs.create({ software: [common], - toolKits: [mathToolKit], + bindingGroups: [mathBindingGroup], permissions: ALLOW_ALL_VM_PERMISSIONS, }); }, 20_000); @@ -53,24 +53,24 @@ describe("native sidecar tool dispatch", () => { await vm?.dispose(); }); - test("agentos list-tools returns registered toolkits", async () => { - const result = await runCommand(vm, "agentos", ["list-tools"]); + test("agentos list-bindings returns registered bindingGroups", async () => { + const result = await runCommand(vm, "agentos", ["list-bindings"]); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ ok: true, result: { - toolkits: [ + bindingGroups: [ { name: "math", description: "Math utilities", - tools: ["add"], + bindings: ["add"], }, ], }, }); }); - test("agentos- executes the tool through the sidecar", async () => { + test("agentos- executes the binding through the sidecar", async () => { const result = await runCommand(vm, "agentos-math", [ "add", "--a", @@ -87,16 +87,16 @@ describe("native sidecar tool dispatch", () => { test("guest shell scripts can invoke agentos-* commands through PATH", async () => { await vm.writeFile( - "/tmp/run-tool.sh", + "/tmp/run-binding.sh", [ "#!/bin/sh", "set -eu", - "agentos-math add --a 2 --b 3 > /tmp/tool-output.json", + "agentos-math add --a 2 --b 3 > /tmp/binding-output.json", ].join("\n"), ); const result = await vm.exec( - "sh /tmp/run-tool.sh && cat /tmp/tool-output.json", + "sh /tmp/run-binding.sh && cat /tmp/binding-output.json", ); expect(result.exitCode).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ @@ -105,7 +105,7 @@ describe("native sidecar tool dispatch", () => { }); }); - test("invalid tool input exits non-zero and writes the error to stderr", async () => { + test("invalid binding input exits non-zero and writes the error to stderr", async () => { const result = await runCommand(vm, "agentos-math", ["add", "--a", "5"]); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Missing required flag"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858619bb0..1f32946ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,21 +296,6 @@ importers: '@agentos-software/common': specifier: link:../../../secure-exec/registry/software/common version: link:../../../secure-exec/registry/software/common - '@agentos-software/curl': - specifier: link:../../../secure-exec/registry/software/curl - version: link:../../../secure-exec/registry/software/curl - '@agentos-software/git': - specifier: link:../../../secure-exec/registry/software/git - version: link:../../../secure-exec/registry/software/git - '@agentos-software/jq': - specifier: link:../../../secure-exec/registry/software/jq - version: link:../../../secure-exec/registry/software/jq - '@agentos-software/ripgrep': - specifier: link:../../../secure-exec/registry/software/ripgrep - version: link:../../../secure-exec/registry/software/ripgrep - '@agentos-software/sqlite3': - specifier: link:../../../secure-exec/registry/software/sqlite3 - version: link:../../../secure-exec/registry/software/sqlite3 '@rivet-dev/agentos': specifier: workspace:* version: link:../../packages/agentos @@ -1390,7 +1375,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/cron: + examples/quickstart/bindings: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1439,7 +1424,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/filesystem: + examples/quickstart/cron: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1488,7 +1473,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/git: + examples/quickstart/filesystem: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1537,7 +1522,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/hello-world: + examples/quickstart/git: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1586,7 +1571,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/network: + examples/quickstart/hello-world: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1635,7 +1620,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/nodejs: + examples/quickstart/network: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1684,7 +1669,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/pi-extensions: + examples/quickstart/nodejs: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1733,7 +1718,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/processes: + examples/quickstart/pi-extensions: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1782,7 +1767,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/s3-filesystem: + examples/quickstart/processes: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1831,7 +1816,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/sandbox: + examples/quickstart/s3-filesystem: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -1880,7 +1865,7 @@ importers: specifier: ^5.7.2 version: 5.9.3 - examples/quickstart/tools: + examples/quickstart/sandbox: dependencies: '@agentos-software/claude-code': specifier: 'catalog:' diff --git a/scripts/check-secure-exec-package-boundary.mjs b/scripts/check-secure-exec-package-boundary.mjs index 4b72dd9d8..35523c0fb 100644 --- a/scripts/check-secure-exec-package-boundary.mjs +++ b/scripts/check-secure-exec-package-boundary.mjs @@ -46,11 +46,11 @@ const importSpecifierPatterns = [ ]; const agentOsFacadeSymbolPatterns = [ { label: "AgentOs", pattern: /\bAgentOs\b/ }, - { label: "HostTool", pattern: /\bHostTool\b/ }, - { label: "ToolKit", pattern: /\bToolKit\b/ }, + { label: "Binding", pattern: /\bBinding\b/ }, + { label: "BindingGroup", pattern: /\bBindingGroup\b/ }, { label: "registerToolkit", pattern: /\bregisterToolkit\b/ }, - { label: "register_toolkit", pattern: /\bregister_toolkit\b/ }, - { label: "toolkit_registered", pattern: /\btoolkit_registered\b/ }, + { label: "register_binding_group", pattern: /\bregister_binding_group\b/ }, + { label: "binding_group_registered", pattern: /\bbinding_group_registered\b/ }, { label: "SidecarRegisteredToolDefinition", pattern: /\bSidecarRegisteredToolDefinition\b/, @@ -276,7 +276,7 @@ function checkAgentOsFacadeSymbols(root, packageDir, packageName, errors) { pattern.lastIndex = 0; if (pattern.test(source)) { errors.push( - `${packageName} must not expose Agent OS facade/toolkit symbol ${label} (${formatPath(root, filePath)})`, + `${packageName} must not expose Agent OS facade/binding symbol ${label} (${formatPath(root, filePath)})`, ); } } diff --git a/scripts/check-secure-exec-package-boundary.test.mjs b/scripts/check-secure-exec-package-boundary.test.mjs index 06416d275..69ca0a91e 100644 --- a/scripts/check-secure-exec-package-boundary.test.mjs +++ b/scripts/check-secure-exec-package-boundary.test.mjs @@ -280,7 +280,7 @@ test("rejects Agent OS source imports", () => { }); }); -test("rejects Agent OS facade and toolkit symbols in secure-exec sources", () => { +test("rejects Agent OS facade and binding symbols in secure-exec sources", () => { withFixture((root) => { writePackage( root, @@ -295,17 +295,17 @@ test("rejects Agent OS facade and toolkit symbols in secure-exec sources", () => }, { "src/index.ts": - "export class AgentOs {}\nexport function registerToolkit(tool: HostTool): ToolKit { return tool; }\nexport const type = 'register_toolkit';\nexport const result = 'toolkit_registered';\n", + "export class AgentOs {}\nexport function registerToolkit(tool: Binding): BindingGroup { return tool; }\nexport const type = 'register_binding_group';\nexport const result = 'binding_group_registered';\n", }, ); assert.deepEqual(checkSecureExecPackageBoundary({ root }), [ - "@secure-exec/core must not expose Agent OS facade/toolkit symbol AgentOs (packages/secure-exec-core/src/index.ts)", - "@secure-exec/core must not expose Agent OS facade/toolkit symbol HostTool (packages/secure-exec-core/src/index.ts)", - "@secure-exec/core must not expose Agent OS facade/toolkit symbol ToolKit (packages/secure-exec-core/src/index.ts)", - "@secure-exec/core must not expose Agent OS facade/toolkit symbol registerToolkit (packages/secure-exec-core/src/index.ts)", - "@secure-exec/core must not expose Agent OS facade/toolkit symbol register_toolkit (packages/secure-exec-core/src/index.ts)", - "@secure-exec/core must not expose Agent OS facade/toolkit symbol toolkit_registered (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol AgentOs (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol Binding (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol BindingGroup (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol registerToolkit (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol register_binding_group (packages/secure-exec-core/src/index.ts)", + "@secure-exec/core must not expose Agent OS facade/binding symbol binding_group_registered (packages/secure-exec-core/src/index.ts)", ]); }); }); diff --git a/scripts/check-stale-split-names.mjs b/scripts/check-stale-split-names.mjs index a4a3070c4..7e689911d 100644 --- a/scripts/check-stale-split-names.mjs +++ b/scripts/check-stale-split-names.mjs @@ -122,7 +122,7 @@ const stalePatterns = [ }, { name: "legacy host callback registration wire name", - pattern: /\bregister_toolkit\b/g, + pattern: /\bregister_binding_group\b/g, replacement: "registerHostCallbacks or RegisterHostCallbacks", }, { diff --git a/scripts/check-stale-split-names.test.mjs b/scripts/check-stale-split-names.test.mjs index 80396de18..cad1ca19c 100644 --- a/scripts/check-stale-split-names.test.mjs +++ b/scripts/check-stale-split-names.test.mjs @@ -117,11 +117,11 @@ test("rejects stale host callback registration docs", () => { write( root, "packages/core/CLAUDE.md", - "- Host tool registration still uses register_toolkit on the wire.\n", + "- Host binding registration still uses register_binding_group on the wire.\n", ); assert.deepEqual(checkStaleSplitNames({ root }), [ - "packages/core/CLAUDE.md:1:37 uses legacy host callback registration wire name register_toolkit; use registerHostCallbacks or RegisterHostCallbacks", + "packages/core/CLAUDE.md:1:37 uses legacy host callback registration wire name register_binding_group; use registerHostCallbacks or RegisterHostCallbacks", ]); }); }); diff --git a/scripts/check-ts-split-boundary.mjs b/scripts/check-ts-split-boundary.mjs index 58601055d..0080e5ac2 100644 --- a/scripts/check-ts-split-boundary.mjs +++ b/scripts/check-ts-split-boundary.mjs @@ -20,8 +20,8 @@ const requiredAgentOsExports = [ "AgentOs", "AgentOsSidecar", "CronManager", - "hostTool", - "toolKit", + "binding", + "bindingGroup", "defineSoftware", ]; const requiredAgentOsMethods = [ @@ -191,7 +191,7 @@ export function auditTsSplitBoundary(options = {}) { const forbiddenSecureExecMatch = hasAnySourceMatch(secureExecCoreRoot, [ { label: "@rivet-dev/agentos import", pattern: /@rivet-dev\/agent-os/g }, { label: "AgentOs facade", pattern: /\bclass\s+AgentOs\b|\bexport\s+\{\s*AgentOs\b/g }, - { label: "Agent OS host-tools sugar", pattern: /\bhostTool\b|\btoolKit\b|\bzodToJsonSchema\b/g }, + { label: "Agent OS host-bindings sugar", pattern: /\bbinding\b|\bbindingGroup\b|\bzodToJsonSchema\b/g }, { label: "Agent OS cron sugar", pattern: /\bCronManager\b|\bTimerScheduleDriver\b/g }, { label: "Agent OS defineSoftware sugar", pattern: /\bdefineSoftware\b/g }, ]); @@ -240,8 +240,8 @@ export function auditTsSplitBoundary(options = {}) { ); for (const file of [ - "src/host-tools.ts", - "src/host-tools-zod.ts", + "src/host-bindings.ts", + "src/host-bindings-zod.ts", "src/packages.ts", "src/cron/index.ts", "src/cron/cron-manager.ts", diff --git a/scripts/check-ts-split-boundary.test.mjs b/scripts/check-ts-split-boundary.test.mjs index 6ffcc1d06..94f3c942e 100644 --- a/scripts/check-ts-split-boundary.test.mjs +++ b/scripts/check-ts-split-boundary.test.mjs @@ -74,7 +74,7 @@ function seedReadyFixture(root) { [ 'export { AgentOs, AgentOsSidecar } from "./agent-os.js";', 'export { CronManager } from "./cron/index.js";', - 'export { hostTool, toolKit } from "./host-tools.js";', + 'export { binding, bindingGroup } from "./host-bindings.js";', 'export { defineSoftware } from "./packages.js";', "", ].join("\n"), @@ -134,8 +134,8 @@ function seedReadyFixture(root) { 'import { NativeSidecarProcessClient } from "@secure-exec/core/sidecar-client";\n', ); for (const file of [ - "host-tools.ts", - "host-tools-zod.ts", + "host-bindings.ts", + "host-bindings-zod.ts", "packages.ts", "cron/index.ts", "cron/cron-manager.ts", @@ -171,7 +171,7 @@ test("reports Agent OS facade regressions in secure-exec core", () => { 'export * as protocol from "./generated-protocol.js";', 'export * from "./generated-protocol.js";', "export class AgentOs {}", - "export function hostTool() {}", + "export function binding() {}", "", ].join("\n"), ); @@ -182,6 +182,6 @@ test("reports Agent OS facade regressions in secure-exec core", () => { ); assert(boundaryCheck); assert.equal(boundaryCheck.ok, false); - assert.match(boundaryCheck.details, /AgentOs facade|Agent OS host-tools sugar/); + assert.match(boundaryCheck.details, /AgentOs facade|Agent OS host-bindings sugar/); }); }); diff --git a/website/public/docs/docs/bindings.md b/website/public/docs/docs/bindings.md index 765d62693..59d56b833 100644 --- a/website/public/docs/docs/bindings.md +++ b/website/public/docs/docs/bindings.md @@ -6,7 +6,7 @@ Expose your host JavaScript functions (defined with Zod input schemas) to agents ## Getting started -Define a bindings group with Zod input schemas and pass it to `agentOS()`. Each binding becomes a CLI command inside the VM. +Define a bindings group with Zod input schemas and pass it to `agentOS({ bindings: [...] })`. Each binding becomes a CLI command inside the VM. Each binding can set an explicit `timeout` (in milliseconds) for long-running work. Bindings run without a timeout unless one is set. @@ -30,14 +30,12 @@ When bindings are registered, CLI shims are installed at `/usr/local/bin/agentos The agent interacts with bindings as shell commands: -The listing subcommand is still named `list-tools` for CLI compatibility. - ```bash # List all available bindings groups -agentos list-tools +agentos list-bindings # List bindings in a specific group -agentos list-tools weather +agentos list-bindings weather # Get help for a binding agentos-weather forecast --help @@ -82,4 +80,4 @@ Use bindings when you want to expose your own JavaScript functions to agents. Us Binding calls from the agent securely invoke your `execute()` functions on the host. Your functions run with full access to the host environment, so you can call databases, APIs, and services directly without proxying credentials into the VM. The agent never sees the credentials, it only sees the binding's input/output contract. -Bindings run on the host with full access to the host environment, so do not expose bindings that could compromise the host without appropriate safeguards. \ No newline at end of file +Bindings run on the host with full access to the host environment, so do not expose bindings that could compromise the host without appropriate safeguards. diff --git a/website/public/docs/docs/crash-course.md b/website/public/docs/docs/crash-course.md index 6f6fb9f8d..c5b23d92b 100644 --- a/website/public/docs/docs/crash-course.md +++ b/website/public/docs/docs/crash-course.md @@ -82,6 +82,6 @@ Schedule recurring commands and agent sessions with cron expressions. ### Sandbox Mounting -agentOS uses a hybrid model: agents run in a lightweight VM by default and mount a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. Mount the sandbox as a filesystem and expose its process management as bindings. +agentOS uses a hybrid model: agents run in a lightweight VM by default and can mount a full sandbox for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. In direct `AgentOs.create(...)` code, start one sandbox for that VM, mount its filesystem, expose its process management as bindings, and dispose both resources together. -[Documentation](/docs/sandbox) \ No newline at end of file +[Documentation](/docs/sandbox) diff --git a/website/public/docs/docs/sandbox.md b/website/public/docs/docs/sandbox.md index fa842ddd7..4491de7be 100644 --- a/website/public/docs/docs/sandbox.md +++ b/website/public/docs/docs/sandbox.md @@ -2,11 +2,11 @@ Extend agentOS with full sandboxes for heavy workloads like browsers, desktop automation, and compilation. -For heavy workloads like browsers, desktop automation, and compilation, pair agentOS with a full sandbox on demand. Its filesystem mounts into the VM as a native directory, and its process management is exposed as [bindings](/docs/bindings), all provider-agnostic through [Sandbox Agent](https://sandboxagent.dev). +For heavy workloads like browsers, desktop automation, and compilation, pair agentOS with a full sandbox on demand. Its filesystem mounts into the VM as a native directory, and its process management is exposed through host bindings, all provider-agnostic through [Sandbox Agent](https://sandboxagent.dev). ## Why use agentOS with a sandbox? -agentOS is an alternative to sandboxes that covers most use cases, but some workloads need a full sandbox for special kinds of software (browsers, desktop automation, heavy compilation). Sandbox mounting lets you lazily start a sandbox on demand, only when it is needed, and project it into the VM. The hybrid model means one agent session can handle both lightweight coding tasks and heavy system operations, using the right tool for each. +agentOS is an alternative to sandboxes that covers most use cases, but some workloads need a full sandbox for special kinds of software (browsers, desktop automation, heavy compilation). Sandbox mounting lets you lazily start a sandbox on demand, only when it is needed, and project it into the VM. The hybrid model means one agent session can handle both lightweight coding tasks and heavy system operations, using the right binding for each. See [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison. @@ -25,7 +25,7 @@ Start with the default agentOS VM for all workloads, and only spin up a sandbox The sandbox integration ships as the `@rivet-dev/agentos-sandbox` package. It works through two mechanisms: - **Filesystem mount**: Projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine. Read and write files through the mount directly. -- **Bindings**: Exposes sandbox process management as [bindings](/docs/bindings). Execute commands on the sandbox from within the VM. +- **Bindings**: Exposes sandbox process management as the `agentos-sandbox` command inside the VM. Both are powered by [Sandbox Agent](https://sandboxagent.dev), and you can swap providers without changing agent code. Install both packages: @@ -35,9 +35,11 @@ npm install @rivet-dev/agentos-sandbox sandbox-agent `createSandboxFs` and `createSandboxBindings` come from `@rivet-dev/agentos-sandbox`. `SandboxAgent` and the provider helpers (such as `docker`) come from the `sandbox-agent` package. +**Warning:** do not create one `SandboxAgent` at module scope and reuse it for multiple actor instances. Direct `AgentOs.create(...)` examples should start one sandbox for the one VM they create and dispose both together. Dynamic per-actor sandbox creation for `agentOS(...)` needs a future actor-scoped options hook; no `createOptions` callback is supported today. + ## Calling the mounted bindings -Once the sandbox is mounted, write code through the filesystem and run it inside the sandbox. The sandbox bindings are exposed inside the VM as a CLI command, so you call it through the same `exec`/`spawn` surface as any other command. +Once the sandbox is mounted, write code through the filesystem and run it inside the sandbox. The sandbox bindings are exposed inside the VM as a CLI command, so you call them through the same `exec`/`spawn` surface as any other command. ## Bindings reference @@ -66,4 +68,4 @@ agentos-sandbox send-input --id "proc_abc123" --data "yes" ## Sandbox providers -The extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions. \ No newline at end of file +The extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions. diff --git a/website/src/content/docs/docs/bindings.mdx b/website/src/content/docs/docs/bindings.mdx index 4c57593f6..b8a7d3226 100644 --- a/website/src/content/docs/docs/bindings.mdx +++ b/website/src/content/docs/docs/bindings.mdx @@ -8,7 +8,7 @@ Expose your host JavaScript functions (defined with Zod input schemas) to agents ## Getting started -Define a bindings group with Zod input schemas and pass it to `agentOS()`. Each binding becomes a CLI command inside the VM. +Define a bindings group with Zod input schemas and pass it to `agentOS({ bindings: [...] })`. Each binding becomes a CLI command inside the VM. @@ -38,14 +38,12 @@ When bindings are registered, CLI shims are installed at `/usr/local/bin/agentos The agent interacts with bindings as shell commands: -The listing subcommand is still named `list-tools` for CLI compatibility. - ```bash # List all available bindings groups -agentos list-tools +agentos list-bindings # List bindings in a specific group -agentos list-tools weather +agentos list-bindings weather # Get help for a binding agentos-weather forecast --help diff --git a/website/src/content/docs/docs/crash-course.mdx b/website/src/content/docs/docs/crash-course.mdx index 2fa9922b0..309e9b79c 100644 --- a/website/src/content/docs/docs/crash-course.mdx +++ b/website/src/content/docs/docs/crash-course.mdx @@ -150,7 +150,7 @@ Schedule recurring commands and agent sessions with cron expressions. ### Sandbox Mounting -agentOS uses a hybrid model: agents run in a lightweight VM by default and mount a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. Mount the sandbox as a filesystem and expose its process management as bindings. +agentOS uses a hybrid model: agents run in a lightweight VM by default and can mount a full sandbox for heavy workloads like browsers, compilation, and desktop automation. Sandboxes are powered by [Sandbox Agent](https://sandboxagent.dev), so you can swap providers without changing agent code. In direct `AgentOs.create(...)` code, start one sandbox for that VM, mount its filesystem, expose its process management as bindings, and dispose both resources together. diff --git a/website/src/content/docs/docs/sandbox.mdx b/website/src/content/docs/docs/sandbox.mdx index 652c300cb..da009479b 100644 --- a/website/src/content/docs/docs/sandbox.mdx +++ b/website/src/content/docs/docs/sandbox.mdx @@ -4,11 +4,11 @@ description: "Extend agentOS with full sandboxes for heavy workloads like browse skill: true --- -For heavy workloads like browsers, desktop automation, and compilation, pair agentOS with a full sandbox on demand. Its filesystem mounts into the VM as a native directory, and its process management is exposed as [bindings](/docs/bindings), all provider-agnostic through [Sandbox Agent](https://sandboxagent.dev). +For heavy workloads like browsers, desktop automation, and compilation, pair agentOS with a full sandbox on demand. Its filesystem mounts into the VM as a native directory, and its process management is exposed through host bindings, all provider-agnostic through [Sandbox Agent](https://sandboxagent.dev). ## Why use agentOS with a sandbox? -agentOS is an alternative to sandboxes that covers most use cases, but some workloads need a full sandbox for special kinds of software (browsers, desktop automation, heavy compilation). Sandbox mounting lets you lazily start a sandbox on demand, only when it is needed, and project it into the VM. The hybrid model means one agent session can handle both lightweight coding tasks and heavy system operations, using the right tool for each. +agentOS is an alternative to sandboxes that covers most use cases, but some workloads need a full sandbox for special kinds of software (browsers, desktop automation, heavy compilation). Sandbox mounting lets you lazily start a sandbox on demand, only when it is needed, and project it into the VM. The hybrid model means one agent session can handle both lightweight coding tasks and heavy system operations, using the right binding for each. See [agentOS vs Sandbox](/docs/versus-sandbox) for a detailed comparison. @@ -27,7 +27,7 @@ Start with the default agentOS VM for all workloads, and only spin up a sandbox The sandbox integration ships as the `@rivet-dev/agentos-sandbox` package. It works through two mechanisms: - **Filesystem mount**: Projects the sandbox into the VM as a native directory, like mounting a hard drive on your own machine. Read and write files through the mount directly. -- **Bindings**: Exposes sandbox process management as [bindings](/docs/bindings). Execute commands on the sandbox from within the VM. +- **Bindings**: Exposes sandbox process management as the `agentos-sandbox` command inside the VM. Both are powered by [Sandbox Agent](https://sandboxagent.dev), and you can swap providers without changing agent code. Install both packages: @@ -37,13 +37,15 @@ npm install @rivet-dev/agentos-sandbox sandbox-agent `createSandboxFs` and `createSandboxBindings` come from `@rivet-dev/agentos-sandbox`. `SandboxAgent` and the provider helpers (such as `docker`) come from the `sandbox-agent` package. +**Warning:** do not create one `SandboxAgent` at module scope and reuse it for multiple actor instances. Direct `AgentOs.create(...)` examples should start one sandbox for the one VM they create and dispose both together. Dynamic per-actor sandbox creation for `agentOS(...)` needs a future actor-scoped options hook; no `createOptions` callback is supported today. + -## Calling the mounted bindings + -Once the sandbox is mounted, write code through the filesystem and run it inside the sandbox. The sandbox bindings are exposed inside the VM as a CLI command, so you call it through the same `exec`/`spawn` surface as any other command. +## Calling the mounted bindings - +Once the sandbox is mounted, write code through the filesystem and run it inside the sandbox. The sandbox bindings are exposed inside the VM as a CLI command, so you call them through the same `exec`/`spawn` surface as any other command. ## Bindings reference @@ -73,4 +75,3 @@ agentos-sandbox send-input --id "proc_abc123" --data "yes" ## Sandbox providers The extension works with any [Sandbox Agent](https://sandboxagent.dev) provider. See the [Sandbox Agent documentation](https://sandboxagent.dev) for available providers and setup instructions. - diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 4fdfdbd87..38eb05da8 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -12,14 +12,14 @@ import type { registry } from "./server"; const client = createClient("http://localhost:6420"); const agent = client.vm.getOrCreate("my-agent").connect(); -// Stream events (tool calls, text output, etc.) +// Stream events (binding calls, text output, etc.) agent.on("sessionEvent", (data) => console.log(data.event)); // Create a session and send a prompt const session = await agent.createSession("pi", { env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! } }); await agent.sendPrompt(session.sessionId, "Write a Python script that calculates pi");` }, - { key: 'tools', fileName: 'tools.ts', code: `// server.ts + { key: 'bindings', fileName: 'bindings.ts', code: `// server.ts import { agentOS, setup } from "@rivet-dev/agentos"; import common from "@agentos-software/common"; import { z } from "zod"; @@ -291,38 +291,35 @@ await agent.exec("git -C /home/agentos/repo commit -m 'add feature'"); const content = await agent.readFile("/home/agentos/repo/feature.txt"); console.log(new TextDecoder().decode(content));` }, - { key: 'sandbox', fileName: 'sandbox.ts', code: `// server.ts -import { agentOS, setup } from "@rivet-dev/agentos"; -import { createSandboxFs, createSandboxBindings } from "@rivet-dev/agentos-sandbox"; + { key: 'sandbox', fileName: 'server.ts', code: `// server.ts +import { AgentOs } from "@rivet-dev/agentos-core"; +import { createSandboxBindings, createSandboxFs } from "@rivet-dev/agentos-sandbox"; import { SandboxAgent } from "sandbox-agent"; import { docker } from "sandbox-agent/docker"; -// Start a Docker-backed sandbox -const sandbox = await SandboxAgent.start({ sandbox: docker() }); - -// Mount its filesystem and register the sandbox bindings -const vm = agentOS({ - mounts: [{ path: "/home/agentos/sandbox", plugin: createSandboxFs({ client: sandbox }) }], - bindings: [createSandboxBindings({ client: sandbox })], -}); -export const registry = setup({ use: { vm } }); -registry.start(); +export async function createSandboxVm() { + const sandbox = await SandboxAgent.start({ sandbox: docker() }); + const vm = await AgentOs.create({ + mounts: [ + { path: "/home/agentos/sandbox", plugin: createSandboxFs({ client: sandbox }) }, + ], + bindings: [createSandboxBindings({ client: sandbox })], + }); + return { vm, sandbox }; +} // client.ts -import { createClient } from "@rivet-dev/agentos/client"; -import type { registry } from "./server"; +import { createSandboxVm } from "./server"; -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate("my-agent"); +const { vm, sandbox } = await createSandboxVm(); // Write a file through the mounted sandbox filesystem -await agent.writeFile("/home/agentos/sandbox/hello.txt", "Hello from agentOS!"); -const content = await agent.readFile("/home/agentos/sandbox/hello.txt"); +await vm.writeFile("/home/agentos/sandbox/hello.txt", "Hello from agentOS!"); +const content = await vm.readFile("/home/agentos/sandbox/hello.txt"); console.log(new TextDecoder().decode(content)); -// Run a command inside the Docker sandbox via the mounted toolkit -const result = await agent.exec("agentos-sandbox run-command --command echo --json '{\\"args\\": [\\"hello from Docker\\"]}'"); -console.log(result.stdout);` }, +await vm.dispose(); +await sandbox.dispose();` }, { key: 'permissions', fileName: 'permissions.ts', code: `import { createClient } from "@rivet-dev/agentos/client"; import type { registry } from "./server"; diff --git a/website/src/pages/registry/index.astro b/website/src/pages/registry/index.astro index f9e0dce8b..216e3640f 100644 --- a/website/src/pages/registry/index.astro +++ b/website/src/pages/registry/index.astro @@ -9,7 +9,7 @@ import { HERO_H1_CLASS } from "../../components/marketing/typography";
@@ -19,7 +19,7 @@ import { HERO_H1_CLASS } from "../../components/marketing/typography"; agentOS Registry

- Browse agents, tools, file systems, and sandbox mounting for agentOS. + Browse agents, bindings, file systems, and sandbox mounting for agentOS.