From dc52fb244cd9093ecf718638a87b2a188c2721d3 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 23 Jun 2026 20:10:18 -0700 Subject: [PATCH] feat: native options schema + custom software/agents docs - Native AgentOs options schema (NativeAgentOsOptions) across core/agentos + crates - Re-export defineSoftware from @rivet-dev/agentos - Docs: Reference > Custom Software (Definition, Building Binaries) + Custom Agents guide; onAgentStderr (agent logs) in Sessions --- crates/agentos-actor-plugin/src/config.rs | 5 + crates/client/src/config.rs | 3 + packages/agentos/src/actor.ts | 14 +- packages/agentos/src/config.ts | 57 +++- packages/agentos/src/index.ts | 27 +- packages/agentos/tests/actor.test.ts | 71 ++++- packages/core/package.json | 6 +- packages/core/src/agent-os.ts | 10 + packages/core/src/index.ts | 14 + packages/core/src/options-schema.ts | 296 ++++++++++++++++++ packages/core/tests/options-schema.test.ts | 22 ++ pnpm-lock.yaml | 6 +- website/docs.config.mjs | 9 + .../src/content/docs/docs/agents/custom.mdx | 152 +++++++++ website/src/content/docs/docs/agents/pi.mdx | 4 + .../docs/custom-software/building-wasm.mdx | 86 +++++ .../docs/docs/custom-software/definition.mdx | 156 +++++++++ website/src/content/docs/docs/sessions.mdx | 25 ++ website/src/content/docs/docs/software.mdx | 4 +- 19 files changed, 941 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/options-schema.ts create mode 100644 packages/core/tests/options-schema.test.ts create mode 100644 website/src/content/docs/docs/agents/custom.mdx create mode 100644 website/src/content/docs/docs/custom-software/building-wasm.mdx create mode 100644 website/src/content/docs/docs/custom-software/definition.mdx diff --git a/crates/agentos-actor-plugin/src/config.rs b/crates/agentos-actor-plugin/src/config.rs index 08c2998cc..78ac30e88 100644 --- a/crates/agentos-actor-plugin/src/config.rs +++ b/crates/agentos-actor-plugin/src/config.rs @@ -17,6 +17,11 @@ use anyhow::{Context, Result}; /// Serializable mirror of [`AgentOsConfig`]. `deny_unknown_fields` enforces /// fail-loud behavior when callers pass fields outside this allow-list /// (including non-serializable fields like `schedule_driver`). +/// +/// Keep this struct in sync with +/// `packages/agentos/src/config.ts::nativeAgentOsOptionsSchema` and +/// `packages/agentos/src/actor.ts::buildConfigJson`; TS preflight validation +/// should reject the same native-boundary fields before this serde guard runs. #[derive(serde::Deserialize, Default, Clone)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AgentOsConfigJson { diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 7a61f6f7d..fb8ddee43 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -15,6 +15,9 @@ use serde::{Deserialize, Serialize}; use crate::fs::VirtualFileSystem; /// Resolved client options (= TS `AgentOsOptions`). All fields optional with documented defaults. +/// +/// Keep this Rust mirror in sync with `packages/core/src/agent-os.ts::AgentOsOptions` +/// and `packages/core/src/options-schema.ts::agentOsOptionsSchema`. #[derive(Default)] pub struct AgentOsConfig { /// Software packages to install (flattened). Default `[]`. diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index 59dc68a92..056d39479 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -26,6 +26,7 @@ import { type AgentOsActorConfig, type AgentOsActorConfigInput, agentOsActorConfigSchema, + nativeAgentOsOptionsSchema, } from "./config.js"; import { getPluginPath } from "./plugin-binary.js"; import type { AgentOsActorState, AgentOsActorVars } from "./types.js"; @@ -191,19 +192,18 @@ function flattenSoftware(input: unknown, out: SoftwareDescriptorLike[]): void { export function buildConfigJson( parsed: AgentOsActorConfig, ): string { + const options = nativeAgentOsOptionsSchema.parse( + parsed.options ?? {}, + ) as Record; const descriptors: SoftwareDescriptorLike[] = []; - flattenSoftware( - (parsed.options as { software?: unknown })?.software, - descriptors, - ); + flattenSoftware(options.software, descriptors); // Auto-include the default software bundle (`@agentos-software/common`: `sh` + // coreutils + the standard CLI tools agents rely on) unless the caller opted // out with `defaultSoftware: false`. Anything already listed in `software` // (e.g. an explicit `common`) is not duplicated. Prepended so the baseline // tools come first, matching the previous explicit `[common, ...]` ordering. - const defaultSoftwareEnabled = - (parsed.options as { defaultSoftware?: unknown })?.defaultSoftware !== false; + const defaultSoftwareEnabled = options.defaultSoftware !== false; if (defaultSoftwareEnabled) { const defaults: SoftwareDescriptorLike[] = []; flattenSoftware(common, defaults); @@ -233,7 +233,6 @@ export function buildConfigJson( // no manual `nodeModulesMount(...)`: see `withAutoAgentNodeModulesMount`. An // explicit `/root/node_modules` mount in `options.mounts` always wins. The VM // module resolver reads the mounted tree through the kernel VFS. - const options = (parsed.options ?? {}) as Record; const mounts = withAutoAgentNodeModulesMount( serializeNativeMounts(options.mounts), descriptors, @@ -242,6 +241,7 @@ export function buildConfigJson( return JSON.stringify({ software, additionalInstructions: options.additionalInstructions, + moduleAccessCwd: options.moduleAccessCwd, loopbackExemptPorts: options.loopbackExemptPorts, allowedNodeBuiltins: options.allowedNodeBuiltins, permissions: options.permissions, diff --git a/packages/agentos/src/config.ts b/packages/agentos/src/config.ts index 575e7ac76..746fffbda 100644 --- a/packages/agentos/src/config.ts +++ b/packages/agentos/src/config.ts @@ -1,8 +1,14 @@ import type { AgentOsOptions, JsonRpcNotification, + NativeMountConfig, PermissionRequest, } from "@rivet-dev/agentos-core"; +import { + agentOsOptionFieldSchemas, + nativeMountConfigSchema, + sharedSidecarConfigSchema, +} from "@rivet-dev/agentos-core"; import type { ActorContext, BeforeConnectContext } from "rivetkit"; import { z } from "zod/v4"; import type { AgentOsActorState, AgentOsActorVars } from "./types.js"; @@ -11,13 +17,28 @@ const zFunction = < T extends (...args: any[]) => any = (...args: unknown[]) => unknown, >() => z.custom((val) => typeof val === "function"); -const AgentOsOptionsSchema = z.custom( - (val) => typeof val === "object" && val !== null, -); +export const nativeAgentOsOptionsSchema = z + // Native actor options are a JSON-serializable subset of AgentOsOptions. + // Keep this allow-list in sync with buildConfigJson() and + // crates/agentos-actor-plugin/src/config.rs::AgentOsConfigJson. + .object({ + software: agentOsOptionFieldSchemas.software, + defaultSoftware: agentOsOptionFieldSchemas.defaultSoftware, + loopbackExemptPorts: agentOsOptionFieldSchemas.loopbackExemptPorts, + allowedNodeBuiltins: agentOsOptionFieldSchemas.allowedNodeBuiltins, + rootFilesystem: agentOsOptionFieldSchemas.rootFilesystem, + mounts: z.array(nativeMountConfigSchema).optional(), + moduleAccessCwd: agentOsOptionFieldSchemas.moduleAccessCwd, + additionalInstructions: agentOsOptionFieldSchemas.additionalInstructions, + permissions: agentOsOptionFieldSchemas.permissions, + sidecar: sharedSidecarConfigSchema.optional(), + limits: agentOsOptionFieldSchemas.limits, + }) + .strict(); export const agentOsActorConfigSchema = z .object({ - options: AgentOsOptionsSchema.optional(), + options: nativeAgentOsOptionsSchema.optional(), preview: z .object({ defaultExpiresInSeconds: z.number().positive().default(3600), @@ -33,6 +54,28 @@ export const agentOsActorConfigSchema = z // --- Typed config types (generic callbacks overlaid on the Zod schema) --- +/** + * Type mirror of `nativeAgentOsOptionsSchema`. + * + * Keep this in sync with the schema above and the Rust serde mirror at + * `crates/agentos-actor-plugin/src/config.rs::AgentOsConfigJson`. + */ +export type NativeAgentOsOptions = Pick< + AgentOsOptions, + | "software" + | "defaultSoftware" + | "loopbackExemptPorts" + | "allowedNodeBuiltins" + | "rootFilesystem" + | "moduleAccessCwd" + | "additionalInstructions" + | "permissions" + | "limits" +> & { + mounts?: NativeMountConfig[]; + sidecar?: { kind: "shared"; pool?: string }; +}; + type AgentOsActorContext = ActorContext< AgentOsActorState, TConnParams, @@ -67,13 +110,15 @@ interface AgentOsActorConfigCallbacks { // Parsed config (after Zod defaults/transforms applied). export type AgentOsActorConfig = Omit< z.infer, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" + "options" | "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" > & + { options?: NativeAgentOsOptions } & AgentOsActorConfigCallbacks; // Input config (what users pass in before Zod transforms). export type AgentOsActorConfigInput = Omit< z.input, - "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" + "options" | "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" > & + { options?: NativeAgentOsOptions } & AgentOsActorConfigCallbacks; diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index 8b7b9c46e..aefd1bfcd 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -10,7 +10,10 @@ import { agentOs as createAgentOs, type AgentOsActorDefinition, } from "./actor.js"; -import type { AgentOsActorConfigInput } from "./config.js"; +import type { + AgentOsActorConfigInput, + NativeAgentOsOptions, +} from "./config.js"; export { setup } from "rivetkit"; @@ -24,11 +27,17 @@ export { createAgentOs as agentOs }; export { type AgentOsActorConfig, type AgentOsActorConfigInput, + type NativeAgentOsOptions, agentOsActorConfigSchema, + nativeAgentOsOptionsSchema, } from "./config.js"; export { getPluginPath } from "./plugin-binary.js"; +// Re-export the software-definition helper so custom agents/tools/commands can +// be defined without importing @rivet-dev/agentos-core directly. +export { defineSoftware } from "@rivet-dev/agentos-core"; + export type { AgentOsActionContext, AgentOsActorState, @@ -52,12 +61,24 @@ export type { } from "./types.js"; export type AgentOSActorConfigInput = - AgentOsActorConfigInput["options"]; + NativeAgentOsOptions & + Omit, "options">; export function agentOS( config: AgentOSActorConfigInput = {}, ): AgentOsActorDefinition { + const { + onBeforeConnect, + onSessionEvent, + onPermissionRequest, + preview, + ...options + } = config; return createAgentOs({ - options: config, + options, + preview, + onBeforeConnect, + onSessionEvent, + onPermissionRequest, } as AgentOsActorConfigInput); } diff --git a/packages/agentos/tests/actor.test.ts b/packages/agentos/tests/actor.test.ts index 3e5c4bfd5..17919f636 100644 --- a/packages/agentos/tests/actor.test.ts +++ b/packages/agentos/tests/actor.test.ts @@ -14,6 +14,7 @@ import type { import { createClient } from "rivetkit/client"; import common from "@agentos-software/common"; import { + agentOS, agentOs, buildConfigJson, getPluginPath, @@ -306,7 +307,7 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { test("serializes config and hands plugin paths to the NAPI runtime", () => { const definition = agentOs({ options: { - additionalInstructions: ["stay deterministic"], + additionalInstructions: "stay deterministic", loopbackExemptPorts: [4020], mounts: [nodeModulesMount("/host/project/node_modules")], sidecar: { kind: "shared", pool: "agentos-smoke" }, @@ -329,7 +330,7 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { expect(calls[0].pluginPath).toBe(getPluginPath()); expect(calls[0].sidecarPath).toBe(process.env.AGENTOS_SIDECAR_BIN); expect(JSON.parse(calls[0].configJson)).toMatchObject({ - additionalInstructions: ["stay deterministic"], + additionalInstructions: "stay deterministic", loopbackExemptPorts: [4020], sidecar: { pool: "agentos-smoke" }, mounts: [ @@ -348,6 +349,72 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { }); }); + test("agentOS flat config keeps callbacks outside native VM options", () => { + const definition = agentOS({ + defaultSoftware: false, + software: [], + onSessionEvent: () => {}, + }); + const expectedHandle = Symbol("native-factory") as unknown as ActorFactoryHandle; + const calls: NapiNativePluginOptions[] = []; + const runtime = { + kind: "napi", + createNativePluginFactory(options: NapiNativePluginOptions) { + calls.push(options); + return expectedHandle; + }, + } as CoreRuntime; + + const handle = definition.nativeFactoryBuilder?.(runtime); + + expect(handle).toBe(expectedHandle); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].configJson)).toEqual({ software: [] }); + expect(calls[0].configJson).not.toContain("onSessionEvent"); + }); + + test("rejects native actor options that cannot cross the NAPI config boundary", () => { + expect(() => + agentOs({ + options: { + toolKits: [], + } as never, + }), + ).toThrow(/toolKits/); + + expect(() => + agentOS({ + toolKits: [], + } as never), + ).toThrow(/toolKits/); + + expect(() => + agentOs({ + options: { + mounts: [{ path: "/data", driver: {} }], + } as never, + }), + ).toThrow(/driver/); + + expect(() => + agentOs({ + options: { + sidecar: { kind: "explicit", handle: {} }, + } as never, + }), + ).toThrow(/sidecar/); + }); + + test("buildConfigJson rejects unknown options instead of dropping them", () => { + expect(() => + buildConfigJson({ + options: { + notARealOption: true, + }, + } as never), + ).toThrow(/notARealOption/); + }); + test("buildConfigJson keeps software descriptors pointed at package roots", () => { const configJson = buildConfigJson({ options: { diff --git a/packages/core/package.json b/packages/core/package.json index db0186e68..77238ae94 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -65,7 +65,8 @@ "googleapis": "^144.0.0", "isolated-vm": "^6.0.0", "long-timeout": "^0.1.1", - "minimatch": "^10.2.4" + "minimatch": "^10.2.4", + "zod": "^4.1.11" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.87", @@ -106,7 +107,6 @@ "sandbox-agent": "^0.4.2", "typescript": "^5.7.2", "vitest": "^2.1.8", - "ws": "^8.18.0", - "zod": "^4.1.11" + "ws": "^8.18.0" } } diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index f8f6e8348..c7f1d10e8 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -37,6 +37,7 @@ import type { } from "./agent-session-types.js"; import { type HostTool, type ToolKit, validateToolkits } from "./host-tools.js"; import { zodToJsonSchema } from "./host-tools-zod.js"; +import { parseAgentOsOptions } from "./options-schema.js"; import type { JsonRpcNotification, JsonRpcRequest, @@ -480,6 +481,14 @@ function defaultAgentStderrHandler(event: AgentStderrEvent): void { process.stderr.write(event.chunk); } +/** + * Public core VM options. + * + * Keep this interface in sync with + * `packages/core/src/options-schema.ts::agentOsOptionsSchema`. The Rivet + * native actor intentionally accepts only a subset via + * `packages/agentos/src/config.ts::nativeAgentOsOptionsSchema`. + */ export interface AgentOsOptions { /** * Software to install in the VM. Each entry provides agents, tools, @@ -2535,6 +2544,7 @@ export class AgentOs { } static async create(options?: AgentOsOptions): Promise { + options = parseAgentOsOptions(options); const software = options?.defaultSoftware === false ? (options.software ?? []) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fdf2c0099..9aa036476 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,20 @@ export { toolKit, validateToolkits, } from "./host-tools.js"; +export { + agentOsLimitsSchema, + agentOsOptionFieldSchemas, + agentOsOptionsSchema, + hostToolSchema, + mountConfigSchema, + nativeMountConfigSchema, + parseAgentOsOptions, + permissionsSchema, + rootFilesystemConfigSchema, + sharedSidecarConfigSchema, + sidecarConfigSchema, + toolKitSchema, +} from "./options-schema.js"; export { createInMemoryLayerStore, createSnapshotExport, diff --git a/packages/core/src/options-schema.ts b/packages/core/src/options-schema.ts new file mode 100644 index 000000000..53b4af68e --- /dev/null +++ b/packages/core/src/options-schema.ts @@ -0,0 +1,296 @@ +import { z } from "zod/v4"; +import type { + AgentOsOptions, + AgentStderrHandler, + NativeMountConfig, +} from "./agent-os.js"; +import type { HostTool, ToolKit } from "./host-tools.js"; + +const stringArray = z.array(z.string()); +const nonNegativeInteger = z.number().int().nonnegative(); +const positiveInteger = z.number().int().positive(); +const functionSchema = z.custom<(...args: any[]) => any>( + (value) => typeof value === "function", + { message: "Expected function" }, +); + +const permissionModeSchema = z.enum(["allow", "deny"]); + +const fsPermissionRuleSchema = z + .object({ + mode: permissionModeSchema, + operations: stringArray.optional(), + paths: stringArray.optional(), + }) + .strict(); + +const patternPermissionRuleSchema = z + .object({ + mode: permissionModeSchema, + operations: stringArray.optional(), + patterns: stringArray.optional(), + }) + .strict(); + +const fsRulePermissionsSchema = z + .object({ + default: permissionModeSchema.optional(), + rules: z.array(fsPermissionRuleSchema), + }) + .strict(); + +const patternRulePermissionsSchema = z + .object({ + default: permissionModeSchema.optional(), + rules: z.array(patternPermissionRuleSchema), + }) + .strict(); + +const fsPermissionsSchema = z.union([permissionModeSchema, fsRulePermissionsSchema]); +const patternPermissionsSchema = z.union([ + permissionModeSchema, + patternRulePermissionsSchema, +]); + +export const permissionsSchema = z + .object({ + fs: fsPermissionsSchema.optional(), + network: patternPermissionsSchema.optional(), + childProcess: patternPermissionsSchema.optional(), + process: patternPermissionsSchema.optional(), + env: patternPermissionsSchema.optional(), + tool: patternPermissionsSchema.optional(), + }) + .strict(); + +export const agentOsLimitsSchema = z + .object({ + resources: z + .object({ + cpuCount: positiveInteger.optional(), + maxProcesses: nonNegativeInteger.optional(), + maxOpenFds: nonNegativeInteger.optional(), + maxPipes: nonNegativeInteger.optional(), + maxPtys: nonNegativeInteger.optional(), + maxSockets: nonNegativeInteger.optional(), + maxConnections: nonNegativeInteger.optional(), + maxSocketBufferedBytes: nonNegativeInteger.optional(), + maxSocketDatagramQueueLen: nonNegativeInteger.optional(), + maxFilesystemBytes: nonNegativeInteger.optional(), + maxInodeCount: nonNegativeInteger.optional(), + maxBlockingReadMs: nonNegativeInteger.optional(), + maxPreadBytes: nonNegativeInteger.optional(), + maxFdWriteBytes: nonNegativeInteger.optional(), + maxProcessArgvBytes: nonNegativeInteger.optional(), + maxProcessEnvBytes: nonNegativeInteger.optional(), + maxReaddirEntries: nonNegativeInteger.optional(), + maxWasmFuel: nonNegativeInteger.optional(), + maxWasmMemoryBytes: nonNegativeInteger.optional(), + maxWasmStackBytes: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + http: z + .object({ maxFetchResponseBytes: nonNegativeInteger.optional() }) + .strict() + .optional(), + tools: 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(), + }) + .strict() + .optional(), + plugins: z + .object({ + maxPersistedManifestBytes: nonNegativeInteger.optional(), + maxPersistedManifestFileBytes: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + acp: z + .object({ + maxReadLineBytes: nonNegativeInteger.optional(), + stdoutBufferByteLimit: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + jsRuntime: z + .object({ + v8HeapLimitMb: nonNegativeInteger.optional(), + capturedOutputLimitBytes: nonNegativeInteger.optional(), + stdinBufferLimitBytes: nonNegativeInteger.optional(), + eventPayloadLimitBytes: nonNegativeInteger.optional(), + v8IpcMaxFrameBytes: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + python: z + .object({ + outputBufferMaxBytes: nonNegativeInteger.optional(), + executionTimeoutMs: nonNegativeInteger.optional(), + vfsRpcTimeoutMs: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + wasm: z + .object({ + maxModuleFileBytes: nonNegativeInteger.optional(), + capturedOutputLimitBytes: nonNegativeInteger.optional(), + syncReadLimitBytes: nonNegativeInteger.optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const rootLowerInputSchema = z.union([ + z.object({ kind: z.literal("bundled-base-filesystem") }).strict(), + z.object({ kind: z.literal("snapshot-export"), source: z.unknown() }).strict(), +]); + +export const rootFilesystemConfigSchema = z + .object({ + type: z.literal("overlay").optional(), + mode: z.enum(["ephemeral", "read-only"]).optional(), + disableDefaultBaseLayer: z.boolean().optional(), + lowers: z.array(rootLowerInputSchema).optional(), + }) + .strict(); + +const nativeMountPluginSchema = z + .object({ + id: z.string(), + config: z.unknown().optional(), + }) + .strict(); + +const plainMountConfigSchema = z + .object({ + path: z.string(), + driver: z.custom((value) => typeof value === "object" && value !== null, { + message: "Expected filesystem driver object", + }), + readOnly: z.boolean().optional(), + }) + .strict(); + +export const nativeMountConfigSchema = z + .object({ + path: z.string(), + plugin: nativeMountPluginSchema, + readOnly: z.boolean().optional(), + }) + .strict() as z.ZodType; + +const overlayMountConfigSchema = z + .object({ + path: z.string(), + filesystem: z + .object({ + type: z.literal("overlay"), + store: z.unknown(), + mode: z.enum(["ephemeral", "read-only"]).optional(), + lowers: z.array(z.unknown()), + }) + .strict(), + }) + .strict(); + +export const mountConfigSchema = z.union([ + plainMountConfigSchema, + nativeMountConfigSchema, + overlayMountConfigSchema, +]); + +export const sharedSidecarConfigSchema = z + .object({ + kind: z.literal("shared"), + pool: z.string().optional(), + }) + .strict(); + +const explicitSidecarSchema = z + .object({ + kind: z.literal("explicit"), + handle: z.unknown(), + }) + .strict(); + +export const sidecarConfigSchema = z.union([ + sharedSidecarConfigSchema, + explicitSidecarSchema, +]); + +const toolExampleSchema = z + .object({ + description: z.string(), + input: z.unknown(), + }) + .strict(); + +export const hostToolSchema = 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(), + timeout: nonNegativeInteger.optional(), + }) + .strict() as z.ZodType; + +export const toolKitSchema = z + .object({ + name: z.string(), + description: z.string(), + tools: z.record(z.string(), hostToolSchema), + }) + .strict() as z.ZodType; + +/** + * Shared AgentOsOptions field schemas. + * + * Core uses the full object. The Rivet/native actor composes a narrower + * native-safe subset in `packages/agentos/src/config.ts`; keep that subset and + * `crates/agentos-actor-plugin/src/config.rs::AgentOsConfigJson` aligned when + * adding options that cross the native boundary. + */ +export const agentOsOptionFieldSchemas = { + software: z.array(z.unknown()).optional(), + defaultSoftware: z.boolean().optional(), + loopbackExemptPorts: z.array(z.number().int().min(0).max(65535)).optional(), + allowedNodeBuiltins: stringArray.optional(), + rootFilesystem: rootFilesystemConfigSchema.optional(), + mounts: z.array(mountConfigSchema).optional(), + moduleAccessCwd: z.string().optional(), + additionalInstructions: z.string().optional(), + scheduleDriver: z + .custom((value) => typeof value === "object" && value !== null, { + message: "Expected schedule driver object", + }) + .optional(), + toolKits: z.array(toolKitSchema).optional(), + permissions: permissionsSchema.optional(), + sidecar: sidecarConfigSchema.optional(), + limits: agentOsLimitsSchema.optional(), + onAgentStderr: z.custom( + (value) => typeof value === "function", + { message: "Expected function" }, + ).optional(), +} as const; + +export const agentOsOptionsSchema = z + .object(agentOsOptionFieldSchemas) + .strict() as z.ZodType; + +export function parseAgentOsOptions(options?: AgentOsOptions): AgentOsOptions { + return agentOsOptionsSchema.parse(options ?? {}); +} diff --git a/packages/core/tests/options-schema.test.ts b/packages/core/tests/options-schema.test.ts new file mode 100644 index 000000000..e2fbe8421 --- /dev/null +++ b/packages/core/tests/options-schema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { AgentOs, agentOsOptionsSchema } from "../src/index.js"; + +describe("AgentOsOptions validation", () => { + test("rejects unknown top-level options before booting a VM", async () => { + await expect( + AgentOs.create({ + onSessionEvent: () => {}, + } as never), + ).rejects.toThrow(/onSessionEvent/); + }); + + test("rejects unknown nested permission fields", () => { + expect(() => + agentOsOptionsSchema.parse({ + permissions: { + filesystem: "allow", + }, + }), + ).toThrow(/filesystem/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e4757555..60a55588a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,6 +357,9 @@ importers: minimatch: specifier: ^10.2.4 version: 10.2.5 + zod: + specifier: ^4.1.11 + version: 4.3.6 devDependencies: '@agentos-software/claude-code': specifier: 'catalog:' @@ -475,9 +478,6 @@ importers: ws: specifier: ^8.18.0 version: 8.20.0(bufferutil@4.1.0) - zod: - specifier: ^4.1.11 - version: 4.3.6 packages/dev-shell: dependencies: diff --git a/website/docs.config.mjs b/website/docs.config.mjs index 826717148..2bc5599ac 100644 --- a/website/docs.config.mjs +++ b/website/docs.config.mjs @@ -63,6 +63,7 @@ export const siteConfig = { { slug: "docs/agents/codex", label: "Codex", attrs: { "data-icon-src": "/images/registry/codex.svg" }, badge: { text: "Coming Soon", variant: "caution" } }, { slug: "docs/agents/amp", label: "Amp", attrs: { "data-icon-src": "/images/registry/amp.svg" }, badge: { text: "Coming Soon", variant: "caution" } }, { slug: "docs/agents/opencode", label: "OpenCode", attrs: { "data-icon-src": "/images/registry/opencode.svg" }, badge: { text: "Coming Soon", variant: "caution" } }, + { slug: "docs/agents/custom", label: "Custom Agents", attrs: { "data-icon": "wrench" } }, ], }, { slug: "docs/sessions", label: "Sessions & Transcripts", attrs: { "data-icon": "messages" } }, @@ -101,6 +102,14 @@ export const siteConfig = { items: [ { label: "API Reference", link: "/api", attrs: { target: "_blank" } }, { slug: "docs/deployment", label: "Deploy" }, + { + label: "Custom Software", + items: [ + { slug: "docs/custom-software/definition", label: "Definition" }, + { slug: "docs/custom-software/building-wasm", label: "Building Binaries" }, + { label: "Request Software", link: "https://github.com/rivet-dev/agent-os/issues/new/choose", attrs: { target: "_blank" } }, + ], + }, { label: "Architecture", items: [ diff --git a/website/src/content/docs/docs/agents/custom.mdx b/website/src/content/docs/docs/agents/custom.mdx new file mode 100644 index 000000000..69f1be151 --- /dev/null +++ b/website/src/content/docs/docs/agents/custom.mdx @@ -0,0 +1,152 @@ +--- +title: "Custom Agents" +description: "Bring your own coding agent to agentOS by speaking the Agent Client Protocol (ACP) inside the VM." +--- + +import { Aside } from '@astrojs/starlight/components'; + +A custom agent is a program that runs **inside the VM** to drive a coding agent. agentOS spawns it when you call `createSession()` and talks to it over the Agent Client Protocol. You ship it as a software package, exactly like the built-in agents. + +## Agent Client Protocol (ACP) + +agentOS speaks the [Agent Client Protocol (ACP)](https://agentclientprotocol.com) to every agent: JSON-RPC over stdio. The agent reads protocol messages on **stdin** and writes them on **stdout**, so stdout is reserved for ACP and **stderr is used for logs**. Your program only needs to speak ACP; how it runs the underlying model is up to you. See the [ACP documentation](https://agentclientprotocol.com) for the full protocol. + +## Two ways to build an agent + +There are two shapes, depending on whether the agent runs in the ACP process or in its own. + +### Single process (embedded) + +The ACP adapter **embeds the agent SDK** and runs it in the same process. One process inside the VM, lower memory footprint. + + + + + + + + + Host + + ACP + + VM + + ACP adapter + + agent (embedded) + + +For example, an adapter to run **OpenCode**, which speaks ACP natively. One package is both the ACP process and the agent, so there's no separate adapter and nothing else is spawned. + +```ts title="opencode.ts" +import { defineSoftware } from "@rivet-dev/agentos"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Example: an adapter to run OpenCode. +export default defineSoftware({ + name: "opencode", + type: "agent", + packageDir, + // A single package provides everything that runs in the VM. + requires: ["@agentos-software/opencode"], + agent: { + id: "opencode", + // Same package for both: OpenCode *is* the ACP process. It speaks ACP + // on stdio itself, so there is no separate adapter to spawn the agent. + acpAdapter: "@agentos-software/opencode", + agentPackage: "@agentos-software/opencode", + staticEnv: { + OPENCODE_DISABLE_CONFIG_DEP_INSTALL: "1", + OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", + }, + }, +}); +``` + +### ACP adapter (separate agent) + +The ACP adapter is a thin **bridge** that spawns the real agent as its **own process** (a CLI or SDK) and translates between it and ACP. Full agent feature set, higher memory. + + + + + + + + + Host + + ACP + + VM + + ACP adapter + + spawns + + Agent process + (CLI / SDK) + + +For example, an adapter to run **Pi**: the `pi` CLI doesn't speak ACP, so `pi-acp` speaks ACP and spawns the CLI as a separate process. The descriptor names two packages, the adapter and the agent. + +```ts title="pi-cli.ts" +import { defineSoftware } from "@rivet-dev/agentos"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Example: a pi-acp adapter that runs the Pi CLI. +export default defineSoftware({ + name: "pi-cli", + type: "agent", + packageDir, + // Two separate packages: the ACP adapter, and the agent it drives. + requires: ["pi-acp", "@mariozechner/pi-coding-agent"], + agent: { + id: "pi-cli", + // `pi-acp` is a thin ACP adapter. It does NOT run the agent itself: + // it speaks ACP on stdio and spawns the `pi` CLI as a separate child + // process, translating between ACP and the CLI. + acpAdapter: "pi-acp", + // The actual agent, launched by pi-acp as its own process. + agentPackage: "@mariozechner/pi-coding-agent", + // Tell the adapter where to find the `pi` CLI inside the VM + // (resolved at boot to a guest path under /root/node_modules). + env: (ctx) => ({ + PI_ACP_PI_COMMAND: ctx.resolveBin("@mariozechner/pi-coding-agent", "pi"), + }), + }, +}); +``` + +## Use your agent + +Register the package on the server with `software`. Sessions are then created from the client by `id`, exactly like any built-in agent. + +```ts title="server.ts" +import { agentOS, setup, defineSoftware } from "@rivet-dev/agentos"; + +const myAgent = defineSoftware({ + type: "agent", + /* ...name, packageDir, requires, agent (see above)... */ +}); + +const vm = agentOS({ software: [myAgent] }); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +See [Sessions](/docs/sessions) for creating and driving sessions. Ship your adapter as a package so its dependencies resolve from `node_modules/` (via `requires`) inside the VM, rather than as a loose file. + +All built-in agents are defined exactly this way. Browse them for reference on [GitHub](https://github.com/rivet-dev/agent-os/tree/main/registry/agent). + +## Read more + +- [Defining software packages](/docs/custom-software/definition): the full descriptor reference, including every `agent` field (`staticEnv`, `env`, `launchArgs`), the `SoftwareContext` helpers, and the tool and WASM-command software types. +- [Building binaries](/docs/custom-software/building-wasm): compile WASM command binaries and use the registry. diff --git a/website/src/content/docs/docs/agents/pi.mdx b/website/src/content/docs/docs/agents/pi.mdx index 95519648e..081b13dfe 100644 --- a/website/src/content/docs/docs/agents/pi.mdx +++ b/website/src/content/docs/docs/agents/pi.mdx @@ -79,3 +79,7 @@ const { sessionId } = await agent.createSession("pi", { See the [Pi extension documentation](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions) for the full extension API. +## Customizing the agent + +Pi is a built-in agent, but it's just a software package under the hood. To ship your own ACP adapter, swap the underlying agent SDK, or register a tweaked Pi build as a new agent, see [Custom Agents](/docs/agents/custom). + diff --git a/website/src/content/docs/docs/custom-software/building-wasm.mdx b/website/src/content/docs/docs/custom-software/building-wasm.mdx new file mode 100644 index 000000000..dca12c905 --- /dev/null +++ b/website/src/content/docs/docs/custom-software/building-wasm.mdx @@ -0,0 +1,86 @@ +--- +title: "Building Binaries" +description: "Compile WASM command binaries for agentOS from source in the secure-exec registry." +--- + +import { Aside } from '@astrojs/starlight/components'; + +WASM command packages (`type: "wasm-commands"`) ship **compiled `.wasm` binaries** that run inside the VM as guest commands. The binaries are build artifacts and are not checked into git, so to add or change a command you build it from source in the **secure-exec registry**. + + + +## Where it lives + +Command source and packages live under `registry/` in [secure-exec](https://github.com/rivet-dev/secure-exec/tree/main/registry): + +- **`registry/native/crates/`**: the Rust source for the WASM commands. +- **`registry/native/c/`**: the C source for the WASM commands. +- **`registry/software//`**: the npm package for each command set (`@agentos-software/`). It contains `dist/` (the TypeScript descriptor) and `wasm/` (the compiled binaries it exposes via `commandDir`). + +## Build + +Build everything from `registry/`: + +```bash +make build # build all WASM binaries + the TypeScript packages +make copy-wasm # copy binaries into each package's wasm/ directory +make test +``` + +`copy-wasm` maps each compiled command into `registry/software//wasm/`, which is the `commandDir` the package exposes. The two toolchains build independently: + +### Rust + +Most commands are Rust. The source lives in `registry/native/crates/` and compiles for `wasm32-wasip1` with the pinned **nightly** toolchain from `rust-toolchain.toml` (the build vendors and patches `std` for WASI). Build just the Rust commands: + +```bash +make build-wasm-rust # runs: cd native && make wasm +``` + +### C + +C-based commands (e.g. `sqlite3`, `unzip`, `wget`, `zip`) live in `registry/native/c/` and compile with a **wasi-sdk** clang toolchain. Build just the C commands: + +```bash +make build-wasm-c # runs: cd native/c && make programs && make install +``` + +## Add a new command package + +1. Add the command source under `registry/native/crates/` (Rust) or `registry/native/c/` (C). +2. Create `registry/software//` as an `@agentos-software/` npm package that exports a descriptor with its `commandDir`. +3. Add a copy rule to the `copy-wasm` target mapping the built binary into `registry/software//wasm/`. +4. If it belongs in a meta-package (e.g. `common` or `build-essential`), add it there. +5. Verify with `make copy-wasm && make build && make test`. + +## Let an agent build it + +This is a mechanical, well-scoped task, so you can hand it to a coding agent. A prompt like: + +```text +Add a WASM command package for `` to the secure-exec registry: +- put the Rust source under registry/native/crates/ (or C under registry/native/c/), +- create registry/software// as an @agentos-software/ npm + package that exports a commandDir descriptor, +- add a copy-wasm rule mapping the built binary into its wasm/ directory, +then run `make copy-wasm && make build && make test` and fix any failures. +``` + +## Using the registry + +Install a published package and pass it to `software`. Registry WASM packages expose a `commandDir`, so you pass them directly (no `defineSoftware()` wrapper): + +```ts title="server.ts" +import { agentOS, setup } from "@rivet-dev/agentos"; +import coreutils from "@agentos-software/coreutils"; +import ripgrep from "@agentos-software/ripgrep"; + +const vm = agentOS({ software: [coreutils, ripgrep] }); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +Meta-packages bundle a full set, e.g. `@agentos-software/common` (coreutils, sed, grep, gawk, findutils, diffutils, tar, gzip). Run the commands from the client; see [Processes & Shell](/docs/processes). Browse the full catalog on the [Registry](/registry), and see how packages map onto the `wasm-commands` descriptor in [Software Definition](/docs/custom-software/definition#wasm-command-software). diff --git a/website/src/content/docs/docs/custom-software/definition.mdx b/website/src/content/docs/docs/custom-software/definition.mdx new file mode 100644 index 000000000..a3567b37e --- /dev/null +++ b/website/src/content/docs/docs/custom-software/definition.mdx @@ -0,0 +1,156 @@ +--- +title: "Software Definition" +description: "The full software-package definition for custom agents, tools, and WASM commands in an agentOS VM." +--- + +import { Aside } from '@astrojs/starlight/components'; + +**Software** is anything you install into a VM: an agent, a host tool, or a set of WASM commands. Every package, including the built-ins, is declared with `defineSoftware()` and passed to the `software` option. + +```ts +import { agentOS, setup } from "@rivet-dev/agentos"; +import pi from "@agentos-software/pi"; + +const vm = agentOS({ software: [pi /*, …more */] }); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +`defineSoftware()` is a typed identity helper. It returns the descriptor unchanged but gives you full type-checking. The descriptor's `type` field discriminates between the three kinds of software. + +## Software types + +- **`"agent"`**: a coding agent runnable via `createSession(id)`. Key fields: `agent`, `requires`, `packageDir`. +- **`"tool"`**: host CLI commands exposed inside the VM. Key fields: `bins`, `requires`, `packageDir`. +- **`"wasm-commands"`**: compiled WASM command binaries. Key fields: `commandDir`, `aliases`, `permissions`. + +All descriptors share two base fields: + +- **`name`** (required). The software package name. +- **`type`** (required). One of `"agent"`, `"tool"`, or `"wasm-commands"`. + +### Agent software + +Registers a coding agent. See [Custom Agents](/docs/agents/custom) for the agent-focused guide; this is the full field reference. + +- **`packageDir`** (required). This package's directory on the host. Resolves `requires` from its own `node_modules/`, mounted into the VM at `/root/node_modules/`. +- **`requires`** (required). npm packages that must be available inside the VM (must include the adapter and agent packages). +- **`agent.id`** (required). The id passed to `createSession(id)`. Reuse a built-in id to override it, or pick a new one. +- **`agent.acpAdapter`** (required). npm package of the ACP adapter, spawned inside the VM. Must be in `requires`. +- **`agent.agentPackage`** (required). npm package of the underlying agent SDK/CLI. Must be in `requires`. +- **`agent.staticEnv`** (optional). Static env vars passed when spawning the adapter (merged **under** user `env`). +- **`agent.env`** (optional). `(ctx: SoftwareContext) => Record`; env computed at boot, e.g. to resolve a bin path. +- **`agent.launchArgs`** (optional). Extra CLI args prepended when launching the adapter. + +```ts title="pi.ts" +import { defineSoftware } from "@rivet-dev/agentos"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export default defineSoftware({ + name: "pi", + type: "agent", + packageDir, + requires: ["@agentos-software/pi", "@mariozechner/pi-coding-agent"], + agent: { + id: "pi", + acpAdapter: "@agentos-software/pi", + agentPackage: "@mariozechner/pi-coding-agent", + }, +}); +``` + +### Tool software + +Exposes one or more host CLI commands inside the VM by mapping a command name to the npm package that provides its `bin`. + +- **`packageDir`** (required). This package's directory on the host (resolves `requires`). +- **`requires`** (required). npm packages that must be available inside the VM. +- **`bins`** (required). `Record`, mapping the command name as invoked in the VM to the npm package providing it. + +```ts title="my-tool.ts" +import { defineSoftware } from "@rivet-dev/agentos"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export default defineSoftware({ + name: "my-tool", + type: "tool", + packageDir, + requires: ["@my-org/my-cli"], + bins: { "my-cli": "@my-org/my-cli" }, +}); +``` + +### WASM command software + +Registers compiled WebAssembly command binaries (coreutils, ripgrep, jq, …). See [Building Binaries](/docs/custom-software/building-wasm) for how to produce the binaries. + +- **`commandDir`** (required). Absolute host path to the directory of `.wasm` command binaries. +- **`aliases`** (optional). `Record`; symlink-style aliases. +- **`permissions`** (optional). Permission-tier assignments: `full`, `readWrite`, `readOnly` (`string[]` or `"*"`), `isolated`. + +```ts title="my-commands.ts" +import { defineSoftware } from "@rivet-dev/agentos"; + +export default defineSoftware({ + name: "my-commands", + type: "wasm-commands", + commandDir: "/abs/path/to/wasm", + aliases: { ll: "ls" }, + permissions: { readOnly: "*" }, +}); +``` + + + +## `SoftwareContext` + +The `agent.env` callback (and other dynamic config) receives a `SoftwareContext` for resolving VM-side paths: + +```ts +interface SoftwareContext { + // Resolve a package's bin to its VM path, e.g. + // ctx.resolveBin("@mariozechner/pi-coding-agent", "pi") + // -> "/root/node_modules/@mariozechner/pi-coding-agent/dist/cli.js" + resolveBin(packageName: string, binName?: string): string; + + // Resolve a package root to its VM path, e.g. + // ctx.resolvePackage("pi-acp") -> "/root/node_modules/pi-acp" + resolvePackage(packageName: string): string; +} +``` + +```ts +agent: { + id: "pi-cli", + acpAdapter: "pi-acp", + agentPackage: "@mariozechner/pi-coding-agent", + env: (ctx) => ({ + PI_ACP_PI_COMMAND: ctx.resolveBin("@mariozechner/pi-coding-agent", "pi"), + }), +} +``` + +## Meta-packages + +A software entry may be an **array** of descriptors, letting one package bundle several (e.g. a "build-essential" set). Pass arrays directly to `software`: + +```ts +const vm = agentOS({ + software: [pi, buildEssential /* = [coreutils, make, git, curl] */], +}); +``` + +## Next steps + +- [Custom Agents](/docs/agents/custom): the agent-focused guide. +- [Building Binaries](/docs/custom-software/building-wasm): compile WASM commands and use the registry. +- [Request Software](https://github.com/rivet-dev/agent-os/issues/new/choose): ask for a package you need. diff --git a/website/src/content/docs/docs/sessions.mdx b/website/src/content/docs/docs/sessions.mdx index 1c70d4bd3..12dc80854 100644 --- a/website/src/content/docs/docs/sessions.mdx +++ b/website/src/content/docs/docs/sessions.mdx @@ -371,3 +371,28 @@ registry.start(); *[See Full Example](https://github.com/rivet-dev/agent-os/tree/main/examples/docs/sessions)* +## Agent logs + +The agent (ACP adapter) runs as a process inside the VM. It uses **stdout** for ACP protocol traffic, so its **stderr** is the channel for logs, warnings, and crash diagnostics. Pass `onAgentStderr` to the VM to capture it, and route it to your own logger to see exactly what the agent is doing (or why it exited). + + + +```ts title="server.ts" +import { agentOS, setup } from "@rivet-dev/agentos"; +import pi from "@agentos-software/pi"; + +const vm = agentOS({ + software: [pi], + onAgentStderr(event) { + // event: { sessionId, agentType, processId, pid, chunk: Uint8Array } + const line = new TextDecoder().decode(event.chunk); + logger.info(`[agent:${event.agentType} session:${event.sessionId}] ${line}`); + }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + diff --git a/website/src/content/docs/docs/software.mdx b/website/src/content/docs/docs/software.mdx index 06072aae2..ff33e255d 100644 --- a/website/src/content/docs/docs/software.mdx +++ b/website/src/content/docs/docs/software.mdx @@ -62,6 +62,6 @@ console.log(result.stdout); Browse all available software packages on the [Registry](/registry). -## Publishing Custom Packages +## Custom Software -See the [agentos-registry contributing guide](https://github.com/rivet-dev/agent-os/blob/main/registry/CONTRIBUTING.md) for how to add new software packages to the registry. +Package your own agents, tools, and WASM commands. See [Software Definition](/docs/custom-software/definition) to define a package, and [Building Binaries](/docs/custom-software/building-wasm) to compile WASM commands from source in the [secure-exec registry](https://github.com/rivet-dev/secure-exec/tree/main/registry).