diff --git a/examples/browser-terminal/.gitignore b/examples/browser-terminal/.gitignore new file mode 100644 index 0000000000..ad0352721a --- /dev/null +++ b/examples/browser-terminal/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +package-lock.json +*.log diff --git a/examples/browser-terminal/README.md b/examples/browser-terminal/README.md new file mode 100644 index 0000000000..0081ae73d1 --- /dev/null +++ b/examples/browser-terminal/README.md @@ -0,0 +1,82 @@ +# Browser Terminal + +A full terminal for Agent OS VMs that runs in the browser, talking to a +[RivetKit](https://rivetkit.org) actor over its live connection — no bespoke +WebSocket server. + +- **Left sidebar** — a list of VMs. Each is one Agent OS VM (one RivetKit actor + instance). The VM ids are kept in `localStorage`, so reopening the page — or + clicking a VM again — reconnects to the same running VM. +- **Tabs** — each VM can have multiple terminal sessions (PTY shells). +- **Reconnect** — because the actor keeps its VM (and shells) alive, a browser + that reconnects re-adopts the running shells and replays their scrollback. + +This example is a **standalone project** (its own `node_modules`, installed from +published npm packages) so it runs without building the agent-os monorepo. + +## Requirements + +- `npm install` pulls everything from npm, including the prebuilt Agent OS + sidecar and the WASM coreutils in `@agentos-software/common`. +- Hosting a RivetKit actor locally uses a local Rivet engine + actor-host envoy. + The engine binary ships with `@rivetkit/engine-cli` (installed automatically), + and the native registry builder that wires it up lives in the sibling + `r6` rivetkit checkout. `npm run server` (via `run-server.mjs`) locates both; + set `AGENTOS_R6_ROOT` if your `r6` checkout is elsewhere. + +## How it works + +``` +Browser (React + xterm.js) Node (RivetKit server, server.ts) + ├─ useActor({ name: "shellVm", key }) ├─ actor "shellVm" + ├─ openShell / writeShell / resize ───▶│ └─ vars.vm = AgentOs.create({ software:[common] }) + ├─ getShellBuffer / listShells │ actions: openShell/writeShell/resizeShell/ + └─ useEvent("shellData" | "shellExit")◀┘ closeShell/listShells/getShellBuffer + onShellData ─▶ broadcast("shellData", { shellId, data }) +``` + +The actor is a hand-written RivetKit actor (not the shipped `agentOS()` actor) +because the browser needs an interactive PTY channel. It wraps the public +`@rivet-dev/agentos-core` shell API (`openShell`, `onShellData`, `writeShell`, +`resizeShell`, `closeShell`) and streams PTY bytes to connected browsers as +`shellData` events (base64), broadcasting `shellExit` when a shell closes. The +VM handle is a non-serializable runtime resource, so it lives in the actor's +`vars`. + +## Run + +```bash +npm install +npm run dev # starts the RivetKit server (engine :6642) and Vite (:5173) +``` + +First boot spawns a local Rivet engine and registers the actor host, which takes +~30–40s; if the page loads before then it will retry until the host is ready. + +Open http://localhost:5173, click **+ New VM**, then **+** to open a terminal +and start typing (`ls`, `echo hi | tr a-z A-Z`, `cd /tmp`, …). + +Run the pieces separately if you prefer: + +```bash +npm run server # RivetKit engine + actor host on :6642 +npm run web # Vite dev server on :5173 +``` + +Override the engine port with `PORT` and the web→engine endpoint with +`VITE_AGENTOS_ENDPOINT` (default `http://localhost:6642`). + +## Notes + +- Software: `@agentos-software/common` (provides `sh`) plus `everything`, `git`, + `http-get`, and `sqlite3` — roughly the tool set the agentos shell ships + (`curl`, `rg`, `grep`, `sed`, `jq`, `tree`, `git`, `sqlite3`, …). Agent OS has + no vim/editor package, so there is no in-VM editor. +- Output is pulled via a `readShell(shellId, offset)` action (an incremental + cursor), not pushed as events: in this local native-registry setup RivetKit + only flushes broadcasts to a connection when another connection is active, so a + lone browser driving its own shell never sees its own output over events. +- The VM shell is line-buffered (it only echoes a line on Enter), so the client + does **local echo + line editing** (printable chars, Backspace, Ctrl-C) and + suppresses the shell's own echo of the submitted line to avoid double display. +- Scrollback replayed on reconnect is bounded (256 KiB per shell) on the server. diff --git a/examples/browser-terminal/index.html b/examples/browser-terminal/index.html new file mode 100644 index 0000000000..280bbdc860 --- /dev/null +++ b/examples/browser-terminal/index.html @@ -0,0 +1,12 @@ + + + + + + Agent OS · Browser Terminal + + +
+ + + diff --git a/examples/browser-terminal/package.json b/examples/browser-terminal/package.json new file mode 100644 index 0000000000..ab14f61cb5 --- /dev/null +++ b/examples/browser-terminal/package.json @@ -0,0 +1,39 @@ +{ + "name": "@rivet-dev/agentos-example-browser-terminal", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Browser terminal for Agent OS VMs — RivetKit actor + xterm.js.", + "scripts": { + "server": "node run-server.mjs", + "web": "vite", + "dev": "concurrently -k -n server,web -c blue,magenta \"node run-server.mjs\" \"vite\"", + "build": "vite build", + "preview": "vite preview", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@agentos-software/common": "0.3.0", + "@agentos-software/everything": "0.3.0", + "@agentos-software/git": "0.3.0", + "@agentos-software/http-get": "0.3.0", + "@agentos-software/sqlite3": "0.3.0", + "@rivet-dev/agentos": "0.2.4", + "@rivet-dev/agentos-core": "0.2.4", + "@rivetkit/react": "0.0.0-feat-dylib-actor-plugin.c44621f", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rivetkit": "0.0.0-feat-dylib-actor-plugin.c44621f" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.0", + "tsx": "^4.19.0", + "typescript": "^5.7.2", + "vite": "^6.0.0" + } +} diff --git a/examples/browser-terminal/run-server.mjs b/examples/browser-terminal/run-server.mjs new file mode 100644 index 0000000000..c1ef9fc06e --- /dev/null +++ b/examples/browser-terminal/run-server.mjs @@ -0,0 +1,70 @@ +// Launches server.ts under the sibling r6 rivetkit checkout's tsx loader + +// tsconfig, because the native registry builder it imports is TS source that +// uses `@/` path aliases. Also resolves the local Rivet engine binary and picks +// the engine port. This mirrors how packages/shell runs its actor-mode VM. +// +// Override the r6 checkout with AGENTOS_R6_ROOT and the port with PORT. + +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); + +const r6Root = + process.env.AGENTOS_R6_ROOT ?? "/home/nathan/.herdr/workspaces/agent-os/r6"; +const r6Rk = join(r6Root, "rivetkit-typescript", "packages", "rivetkit"); +const tsxLoader = join(r6Rk, "node_modules", "tsx", "dist", "loader.mjs"); +const r6Tsconfig = join(r6Rk, "tsconfig.json"); + +if (!existsSync(tsxLoader)) { + console.error( + `Cannot find the r6 rivetkit tsx loader at ${tsxLoader}.\n` + + "This example needs the sibling `r6` rivetkit checkout to host the actor " + + "(the native registry builder is TS source there). Set AGENTOS_R6_ROOT.", + ); + process.exit(1); +} + +let engineBinary = process.env.RIVET_ENGINE_BINARY; +if (!engineBinary) { + try { + const pkg = require.resolve( + "@rivetkit/engine-cli-linux-x64-musl/package.json", + ); + const candidate = join(dirname(pkg), "rivet-engine"); + if (existsSync(candidate)) engineBinary = candidate; + } catch { + // platform package not installed; serve() will report binary_unavailable + } +} + +const port = process.env.PORT ?? "6642"; + +const child = spawn( + process.execPath, + ["--import", tsxLoader, join(here, "server.ts")], + { + cwd: r6Rk, + stdio: "inherit", + env: { + ...process.env, + ESBK_TSCONFIG_PATH: r6Tsconfig, + TSX_TSCONFIG_PATH: r6Tsconfig, + RIVET_RUN_ENGINE_HOST: "127.0.0.1", + RIVET_RUN_ENGINE_PORT: port, + RIVET_TOKEN: process.env.RIVET_TOKEN ?? "dev", + RIVET_NAMESPACE: process.env.RIVET_NAMESPACE ?? "default", + AGENTOS_R6_ROOT: r6Root, + ...(engineBinary ? { RIVET_ENGINE_BINARY: engineBinary } : {}), + RIVETKIT_STORAGE_PATH: + process.env.RIVETKIT_STORAGE_PATH ?? + mkdtempSync(join(tmpdir(), "browser-terminal-")), + }, + }, +); +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/examples/browser-terminal/server.ts b/examples/browser-terminal/server.ts new file mode 100644 index 0000000000..de93148cbb --- /dev/null +++ b/examples/browser-terminal/server.ts @@ -0,0 +1,368 @@ +// Browser Terminal — RivetKit server. +// +// A single custom RivetKit actor ("shellVm") owns one Agent OS VM. Each actor +// instance (keyed by the id kept in the browser's localStorage) is an isolated +// VM. The actor exposes PTY-style shell actions; the browser writes keystrokes +// with `writeShell` and pulls output with `readShell` (an incremental cursor), +// so the whole terminal lives in the browser over RivetKit — no bespoke +// WebSocket server. +// +// Why polling instead of broadcasting output as events: in this local +// native-registry setup RivetKit only flushes queued broadcasts to a connection +// when some *other* connection is active, so a lone browser driving its own +// shell never receives its own output. Request/response actions are reliable, so +// output rides on `readShell`. +// +// Why a hand-written actor instead of the shipped `agentOS()` actor: the browser +// needs an interactive PTY channel (open / write / resize / read / reconnect). +// This actor implements exactly that on top of the public +// `@rivet-dev/agentos-core` `AgentOs` shell API (`openShell`, `onShellData`, +// `writeShell`, `resizeShell`, `closeShell`). The VM handle is a +// non-serializable runtime resource, so it lives in the actor's `vars`. + +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import common from "@agentos-software/common"; +import everything from "@agentos-software/everything"; +import git from "@agentos-software/git"; +import httpGet from "@agentos-software/http-get"; +import sqlite3 from "@agentos-software/sqlite3"; +import { AgentOs } from "@rivet-dev/agentos-core"; +// `setup` from @rivet-dev/agentos raises the RivetKit transport message-size +// caps to never-hit-by-normal-use values (terminal bursts stream as single +// messages). +import { setup } from "@rivet-dev/agentos"; +import { actor } from "rivetkit"; + +/** Per-shell scrollback kept so a reconnecting browser can replay history. */ +const MAX_SCROLLBACK_BYTES = 256 * 1024; + +// One hour: far past any real interaction, but still finite (never Infinity) +// per the limits/observability policy. The stock RivetKit defaults (5s connect, +// 60s action, 30s sleep) would reap a live terminal mid-session. +const NEVER_HIT_MS = 60 * 60 * 1000; + +interface ShellRecord { + unsub: () => void; + /** Bounded scrollback ring (raw PTY bytes). */ + chunks: Uint8Array[]; + /** Bytes currently held in `chunks`. */ + size: number; + /** Monotonic count of bytes ever emitted (the read cursor space). */ + emitted: number; + title: string; + createdAt: number; +} + +interface Vars { + /** Lazily created on the first action — see `ensureVm`. */ + vm: AgentOs | null; + vmPromise: Promise | null; + shells: Map; +} + +interface OpenShellArgs { + cols?: number; + rows?: number; + cwd?: string; + title?: string; +} + +const encodeBytes = (data: Uint8Array): string => + Buffer.from(data).toString("base64"); + +function pushOutput(rec: ShellRecord, data: Uint8Array): void { + rec.chunks.push(data); + rec.size += data.length; + rec.emitted += data.length; + while (rec.size > MAX_SCROLLBACK_BYTES && rec.chunks.length > 1) { + const dropped = rec.chunks.shift(); + if (dropped) rec.size -= dropped.length; + } +} + +/** + * Return the output bytes emitted after `fromOffset`, plus the new cursor. + * Clients poll this to render a shell (echoes + command output) — RivetKit's + * broadcast delivery stalls for a lone connection driving its own actor, so + * output rides on this request/response instead of events. + */ +function readSince( + rec: ShellRecord, + fromOffset: number, +): { offset: number; data: string } { + const bufStart = rec.emitted - rec.size; + const start = Math.max(0, fromOffset - bufStart); + const buf = Buffer.concat(rec.chunks.map((c) => Buffer.from(c))); + return { + offset: rec.emitted, + data: start >= buf.length ? "" : encodeBytes(buf.subarray(start)), + }; +} + +// Booting a VM (spawning the sidecar) takes a few seconds, which is longer than +// RivetKit's actor-ready connection guard. So the VM is created lazily on the +// first action (inside the high `actionTimeout` window) rather than in +// `createVars`, letting the actor become ready — and the browser connect — +// immediately. `common` provides `sh` + coreutils (WASM commands). +function ensureVm(vars: Vars): Promise { + if (vars.vm) return Promise.resolve(vars.vm); + if (!vars.vmPromise) { + const t0 = Date.now(); + console.error("[shellVm] booting VM…"); + vars.vmPromise = AgentOs.create({ + // `common` provides `sh`; `everything` + git/http-get/sqlite3 match the + // tool set the agentos shell ships (git, curl, ripgrep, grep, sed, jq, + // sqlite3, …). There is no vim/editor package in Agent OS, so none is + // included. + software: [common, everything, git, httpGet, sqlite3], + }).then((vm) => { + vars.vm = vm; + console.error(`[shellVm] VM ready in ${Date.now() - t0}ms`); + return vm; + }); + vars.vmPromise.catch((e) => + console.error("[shellVm] VM boot failed:", e), + ); + } + return vars.vmPromise; +} + +const shellVm = actor({ + options: { + // Keep the VM (and therefore its shells) alive so a browser can + // reconnect to running terminal sessions. + noSleep: true, + createVarsTimeout: NEVER_HIT_MS, + onConnectTimeout: NEVER_HIT_MS, + onBeforeConnectTimeout: NEVER_HIT_MS, + actionTimeout: NEVER_HIT_MS, + connectionLivenessTimeout: NEVER_HIT_MS, + sleepTimeout: NEVER_HIT_MS, + maxQueueMessageSize: 512 * 1024 * 1024, + }, + + // Runtime resources (the VM handle) are not serializable, so they live in + // vars, not state. The VM itself is created lazily (see `ensureVm`). + createVars: async (): Promise => { + return { vm: null, vmPromise: null, shells: new Map() }; + }, + + onDestroy: async (c) => { + const vars = c.vars as Vars; + for (const rec of vars.shells.values()) rec.unsub(); + vars.shells.clear(); + try { + await vars.vm?.dispose(); + } catch { + // best-effort teardown + } + }, + + actions: { + /** Open a new PTY-style shell; returns its id. Read output via readShell. */ + openShell: async (c, args?: OpenShellArgs) => { + const vars = c.vars as Vars; + const vm = await ensureVm(vars); + const { shellId } = vm.openShell({ + cols: args?.cols ?? 80, + rows: args?.rows ?? 24, + cwd: args?.cwd, + }); + const rec: ShellRecord = { + unsub: () => {}, + chunks: [], + size: 0, + emitted: 0, + title: args?.title ?? "shell", + createdAt: Date.now(), + }; + rec.unsub = vm.onShellData(shellId, (data) => pushOutput(rec, data)); + vars.shells.set(shellId, rec); + return { shellId }; + }, + + /** Forward keystrokes to a shell's PTY input. */ + writeShell: async (c, shellId: string, data: string) => { + const vars = c.vars as Vars; + // Fail loudly on an unknown shell (e.g. a stale browser tab pointing at + // a shell from a previous VM) instead of silently dropping input. + if (!vars.vm || !vars.shells.has(shellId)) { + throw new Error(`shell not found: ${shellId}`); + } + vars.vm.writeShell(shellId, data); + }, + + /** Notify a shell of a terminal resize. */ + resizeShell: async (c, shellId: string, cols: number, rows: number) => { + const vars = c.vars as Vars; + if (!vars.vm || !vars.shells.has(shellId)) return; + vars.vm.resizeShell(shellId, cols, rows); + }, + + /** Kill a shell and drop it from tracking. */ + closeShell: async (c, shellId: string) => { + const vars = c.vars as Vars; + const rec = vars.shells.get(shellId); + if (rec) rec.unsub(); + try { + vars.vm?.closeShell(shellId); + } catch { + // already gone + } + vars.shells.delete(shellId); + }, + + /** List currently-open shells (for reconnecting browsers). */ + listShells: async (c) => { + const vars = c.vars as Vars; + return [...vars.shells.entries()].map(([shellId, rec]) => ({ + shellId, + title: rec.title, + createdAt: rec.createdAt, + })); + }, + + /** + * Pull shell output emitted after `fromOffset`. Clients poll this to + * render both the reconnect scrollback (fromOffset 0) and live output. + * `gone: true` means the shell no longer exists (drop the tab). + */ + readShell: async (c, shellId: string, fromOffset: number) => { + const vars = c.vars as Vars; + const rec = vars.shells.get(shellId); + if (!rec) return { gone: true, offset: fromOffset, data: "" }; + return { gone: false, ...readSince(rec, fromOffset ?? 0) }; + }, + }, +}); + +// --------------------------------------------------------------------------- +// Local run: RivetKit native engine + slotted actor-host envoy +// --------------------------------------------------------------------------- +// RivetKit hosts the actor on an "envoy" scheduled by a local Rivet engine. +// `buildNativeRegistry(...).serve(...)` spawns the engine and registers a +// slotted envoy that can host this process's actors (the published +// `registry.start()` only registers a zero-slot manager envoy, so the engine +// reports `no_envoys`). After serving we upsert a runner-config for the pool and +// wait for the envoy to register; then the browser can talk to the actor via +// the engine endpoint. This bootstrap mirrors packages/shell's actor mode + +// packages/agentos actor tests; it needs the sibling `r6` rivetkit checkout for +// the native registry builder (run via `npm run server`, which wires the loader +// + engine binary). +const NAMESPACE = process.env.RIVET_NAMESPACE ?? "default"; +const TOKEN = process.env.RIVET_TOKEN ?? "dev"; +const POOL = process.env.RIVET_POOL ?? "default"; +const ENGINE_HOST = process.env.RIVET_RUN_ENGINE_HOST ?? "127.0.0.1"; +const ENGINE_PORT = Number(process.env.RIVET_RUN_ENGINE_PORT ?? 6642); +const ENGINE_ENDPOINT = `http://${ENGINE_HOST}:${ENGINE_PORT}`; +const auth = { Authorization: `Bearer ${TOKEN}` }; + +export const registry = setup({ + use: { shellVm }, + endpoint: ENGINE_ENDPOINT, + namespace: NAMESPACE, + token: TOKEN, + envoy: { poolName: POOL }, + runtime: "native", + shutdown: { disableSignalHandlers: true }, +} as never); + +async function waitForEngineHealth(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(`${ENGINE_ENDPOINT}/health`); + if (r.ok) return; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error(`engine not healthy at ${ENGINE_ENDPOINT}`); +} + +// The engine creates the `default` namespace asynchronously on startup, so the +// datacenter list + runner-config PUT can briefly 400 with "namespace does not +// exist". Retry until the namespace is ready and the upsert succeeds. +async function upsertRunnerConfig(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr = "unknown"; + while (Date.now() < deadline) { + try { + const dcRes = await fetch( + `${ENGINE_ENDPOINT}/datacenters?namespace=${NAMESPACE}`, + { headers: auth }, + ); + if (dcRes.ok) { + const { datacenters } = (await dcRes.json()) as { + datacenters: Array<{ name: string }>; + }; + const dc = datacenters[0]?.name; + if (dc) { + const res = await fetch( + `${ENGINE_ENDPOINT}/runner-configs/${POOL}?namespace=${NAMESPACE}`, + { + method: "PUT", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ datacenters: { [dc]: { normal: {} } } }), + }, + ); + if (res.ok) return; + lastErr = `upsert ${res.status}: ${await res.text()}`; + } else { + lastErr = "no datacenters yet"; + } + } else { + lastErr = `datacenters ${dcRes.status}`; + } + } catch (e) { + lastErr = String(e); + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`runner config not ready in time: ${lastErr}`); +} + +async function waitForEnvoy(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const r = await fetch( + `${ENGINE_ENDPOINT}/envoys?namespace=${NAMESPACE}&name=${POOL}`, + { headers: auth }, + ); + if (r.ok) { + const { envoys } = (await r.json()) as { envoys: unknown[] }; + if (envoys.length > 0) return; + } + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error("timed out waiting for envoy registration"); +} + +// The native registry builder lives in the sibling r6 rivetkit checkout (TS +// source using `@/` aliases), so this file must run under that checkout's tsx +// loader/tsconfig — `npm run server` handles it. +const r6Root = + process.env.AGENTOS_R6_ROOT ?? "/home/nathan/.herdr/workspaces/agent-os/r6"; +const nativeUrl = pathToFileURL( + join( + r6Root, + "rivetkit-typescript/packages/rivetkit/src/registry/native.ts", + ), +).href; +const { buildNativeRegistry } = await import(nativeUrl); +const { registry: nativeRegistry, serveConfig } = await buildNativeRegistry( + (registry as unknown as { parseConfig: () => unknown }).parseConfig(), +); +if (process.env.RIVET_ENGINE_BINARY) { + serveConfig.engineBinaryPath = process.env.RIVET_ENGINE_BINARY; +} +await nativeRegistry.serve(serveConfig); + +await waitForEngineHealth(90_000); +await upsertRunnerConfig(60_000); +await waitForEnvoy(30_000); +console.error( + `[shellVm] ready — engine ${ENGINE_ENDPOINT}, namespace=${NAMESPACE}, pool=${POOL}`, +); diff --git a/examples/browser-terminal/src/ActorView.tsx b/examples/browser-terminal/src/ActorView.tsx new file mode 100644 index 0000000000..74e3b3db49 --- /dev/null +++ b/examples/browser-terminal/src/ActorView.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ACTOR_NAME, useActor } from "./rivet"; +import { type ReadResult, TerminalPane } from "./TerminalPane"; + +interface Tab { + shellId: string; + title: string; +} + +export function ActorView({ actorId }: { actorId: string }) { + const agent = useActor({ name: ACTOR_NAME, key: actorId }); + const conn = agent.connection; + + const [tabs, setTabs] = useState([]); + const [active, setActive] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const initedRef = useRef(false); + + // On (re)connect, adopt any shells already running in the VM. + useEffect(() => { + if (!conn || initedRef.current) return; + initedRef.current = true; + conn + .listShells() + .then((shells: { shellId: string; title: string }[]) => { + if (shells.length === 0) return; + setTabs(shells.map((s) => ({ shellId: s.shellId, title: s.title }))); + setActive(shells[0].shellId); + }) + .catch((e: unknown) => setError(String(e))); + }, [conn]); + + // Reset when switching to a different actor. + useEffect(() => { + initedRef.current = false; + setTabs([]); + setActive(null); + setError(null); + }, [actorId]); + + const openShell = useCallback(async () => { + if (!conn) return; + setBusy(true); + setError(null); + try { + const { shellId } = await conn.openShell({ cols: 80, rows: 24 }); + setTabs((prev) => [ + ...prev, + { shellId, title: `shell ${prev.length + 1}` }, + ]); + setActive(shellId); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }, [conn]); + + const dropTab = useCallback((shellId: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.shellId !== shellId); + setActive((cur) => + cur === shellId ? (next[next.length - 1]?.shellId ?? null) : cur, + ); + return next; + }); + }, []); + + const closeTab = useCallback( + async (shellId: string) => { + dropTab(shellId); + try { + await conn?.closeShell(shellId); + } catch { + // already gone + } + }, + [conn, dropTab], + ); + + return ( +
+
+ {tabs.map((t) => ( +
setActive(t.shellId)} + > + {t.title} + +
+ ))} + + + {conn ? "connected" : "connecting…"} + +
+ + {error &&
{error}
} + +
+ {tabs.length === 0 && ( +
+ {conn + ? "No terminals yet — click + to open one." + : "Connecting to the VM…"} +
+ )} + {tabs.map((t) => ( + conn?.writeShell(t.shellId, text)} + onResize={(cols, rows) => conn?.resizeShell(t.shellId, cols, rows)} + readShell={async (fromOffset): Promise => + conn?.readShell(t.shellId, fromOffset) + } + onGone={dropTab} + /> + ))} +
+
+ ); +} diff --git a/examples/browser-terminal/src/App.tsx b/examples/browser-terminal/src/App.tsx new file mode 100644 index 0000000000..bb5ab20b5a --- /dev/null +++ b/examples/browser-terminal/src/App.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { ActorView } from "./ActorView"; +import { type ActorEntry, createActor, loadActors, saveActors } from "./store"; + +export function App() { + const [actors, setActors] = useState(() => loadActors()); + const [selected, setSelected] = useState( + () => loadActors()[0]?.id ?? null, + ); + + useEffect(() => { + saveActors(actors); + }, [actors]); + + const addActor = () => { + setActors((prev) => { + const entry = createActor(prev); + setSelected(entry.id); + return [...prev, entry]; + }); + }; + + const removeActor = (id: string) => { + setActors((prev) => { + const next = prev.filter((a) => a.id !== id); + setSelected((cur) => (cur === id ? (next[0]?.id ?? null) : cur)); + return next; + }); + }; + + return ( +
+ + +
+ {selected ? ( + + ) : ( +
+ Create a VM to open a terminal. +
+ )} +
+
+ ); +} diff --git a/examples/browser-terminal/src/TerminalPane.tsx b/examples/browser-terminal/src/TerminalPane.tsx new file mode 100644 index 0000000000..41f31d792e --- /dev/null +++ b/examples/browser-terminal/src/TerminalPane.tsx @@ -0,0 +1,182 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef } from "react"; + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +/** How often to pull new shell output. */ +const POLL_MS = 60; + +export interface ReadResult { + gone?: boolean; + offset?: number; + data?: string; +} + +export interface TerminalPaneProps { + shellId: string; + active: boolean; + /** Send raw bytes (already `\n`-terminated) to the shell. */ + onInput: (text: string) => void; + onResize: (cols: number, rows: number) => void; + /** Pull output emitted after `fromOffset`. */ + readShell: (fromOffset: number) => Promise; + /** Called when the shell no longer exists on the server. */ + onGone: (shellId: string) => void; +} + +export function TerminalPane(props: TerminalPaneProps) { + const { shellId, active, onInput, onResize, readShell, onGone } = props; + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + + const onInputRef = useRef(onInput); + const onResizeRef = useRef(onResize); + const readShellRef = useRef(readShell); + const onGoneRef = useRef(onGone); + onInputRef.current = onInput; + onResizeRef.current = onResize; + readShellRef.current = readShell; + onGoneRef.current = onGone; + + // biome-ignore lint/correctness/useExhaustiveDependencies: one-time xterm setup keyed by shellId. + useEffect(() => { + const term = new Terminal({ + convertEol: true, + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 13, + theme: { background: "#0b0e14", foreground: "#c7d0e0" }, + scrollback: 5000, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + termRef.current = term; + fitRef.current = fit; + if (containerRef.current) term.open(containerRef.current); + try { + fit.fit(); + } catch { + // not measurable yet + } + + // ── Local echo + line editing ───────────────────────────────────── + // The VM shell is line-buffered and only echoes a line once you press + // Enter, so keystrokes wouldn't otherwise appear as you type. We echo + // input locally and send whole lines to the shell, then suppress the + // shell's own echo of that line (below) so it isn't shown twice. + let line = ""; + const encoder = new TextEncoder(); + const suppress: number[] = []; // bytes of shell-echo still to swallow + term.onData((data) => { + for (const ch of data) { + const code = ch.codePointAt(0) ?? 0; + if (ch === "\r" || ch === "\n") { + term.write("\r\n"); + const cmd = line; + line = ""; + for (const b of encoder.encode(`${cmd}\n`)) suppress.push(b); + onInputRef.current(`${cmd}\n`); + } else if (code === 0x7f || ch === "\b") { + if (line.length > 0) { + line = line.slice(0, -1); + term.write("\b \b"); + } + } else if (code === 0x03) { + // Ctrl-C: abandon the line, let the shell reset to a new prompt. + line = ""; + onInputRef.current(String.fromCharCode(3)); + } else if (code >= 0x20) { + line += ch; + term.write(ch); + } + // Other control sequences (arrows, tab, …) are intentionally ignored. + } + }); + + // Write shell output, swallowing the shell's echo of what we just sent. + const writeOutput = (bytes: Uint8Array) => { + if (suppress.length === 0) { + term.write(bytes); + return; + } + const out: number[] = []; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (suppress.length > 0) { + if (b === suppress[0]) { + suppress.shift(); + continue; + } + if (b === 0x0d) continue; // CR from the shell's CRLF echo of our LF + suppress.length = 0; // echo diverged — stop swallowing + } + out.push(b); + } + if (out.length > 0) term.write(new Uint8Array(out)); + }; + + term.onResize(({ cols, rows }) => onResizeRef.current(cols, rows)); + + // Poll for output from offset 0 (replays scrollback on attach, then live). + let disposed = false; + let offset = 0; + let timer: ReturnType | undefined; + const tick = async () => { + try { + const res = await readShellRef.current(offset); + if (disposed) return; + if (res?.gone) { + onGoneRef.current(shellId); + return; + } + if (res?.data) writeOutput(base64ToBytes(res.data)); + if (typeof res?.offset === "number") offset = res.offset; + } catch { + // transient; keep polling + } + if (!disposed) timer = setTimeout(tick, POLL_MS); + }; + timer = setTimeout(tick, 0); + + return () => { + disposed = true; + if (timer) clearTimeout(timer); + term.dispose(); + termRef.current = null; + fitRef.current = null; + }; + }, [shellId]); + + // Refit + focus whenever this pane becomes active or the window resizes. + useEffect(() => { + if (!active) return; + const refit = () => { + try { + fitRef.current?.fit(); + termRef.current?.focus(); + } catch { + // ignore + } + }; + refit(); + window.addEventListener("resize", refit); + return () => window.removeEventListener("resize", refit); + }, [active]); + + return ( +
+ ); +} diff --git a/examples/browser-terminal/src/main.tsx b/examples/browser-terminal/src/main.tsx new file mode 100644 index 0000000000..899066b5e4 --- /dev/null +++ b/examples/browser-terminal/src/main.tsx @@ -0,0 +1,10 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +// Note: intentionally not wrapped in . Its dev-only double-mount +// would tear down and recreate each xterm terminal, which is disruptive for a +// live PTY session. +const el = document.getElementById("root"); +if (!el) throw new Error("missing #root"); +createRoot(el).render(); diff --git a/examples/browser-terminal/src/rivet.ts b/examples/browser-terminal/src/rivet.ts new file mode 100644 index 0000000000..8227fd3a05 --- /dev/null +++ b/examples/browser-terminal/src/rivet.ts @@ -0,0 +1,13 @@ +import { createRivetKit } from "@rivetkit/react"; + +// The RivetKit server (server.ts) listens here by default. +const ENDPOINT = + (import.meta.env.VITE_AGENTOS_ENDPOINT as string | undefined) ?? + "http://localhost:6642"; + +// Untyped registry: the actor's action/event surface is exercised by name at +// runtime, which keeps the browser bundle free of any server-only imports. +export const { useActor } = createRivetKit(ENDPOINT); + +/** Name of the actor defined in `setup({ use: { shellVm } })`. */ +export const ACTOR_NAME = "shellVm"; diff --git a/examples/browser-terminal/src/store.ts b/examples/browser-terminal/src/store.ts new file mode 100644 index 0000000000..fb4609cd16 --- /dev/null +++ b/examples/browser-terminal/src/store.ts @@ -0,0 +1,41 @@ +// Actor list persisted in localStorage. Each entry is one Agent OS VM; its +// `id` is used verbatim as the RivetKit actor key, so the same VM is reachable +// across page reloads (and its running shells can be reconnected). + +export interface ActorEntry { + id: string; + name: string; +} + +const KEY = "agentos.browser-terminal.actors"; + +export function loadActors(): ActorEntry[] { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (e): e is ActorEntry => + e && typeof e.id === "string" && typeof e.name === "string", + ); + } catch { + return []; + } +} + +export function saveActors(actors: ActorEntry[]): void { + localStorage.setItem(KEY, JSON.stringify(actors)); +} + +function randomId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `vm-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createActor(existing: ActorEntry[]): ActorEntry { + const n = existing.length + 1; + return { id: `vm-${randomId()}`, name: `VM ${n}` }; +} diff --git a/examples/browser-terminal/src/styles.css b/examples/browser-terminal/src/styles.css new file mode 100644 index 0000000000..95333095ac --- /dev/null +++ b/examples/browser-terminal/src/styles.css @@ -0,0 +1,220 @@ +:root { + --bg: #0b0e14; + --panel: #11151f; + --panel-2: #161b26; + --border: #232a39; + --text: #c7d0e0; + --muted: #7c869c; + --accent: #4c8dff; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + sans-serif; + font-size: 14px; +} + +.app { + display: flex; + height: 100%; +} + +/* Sidebar */ +.sidebar { + width: 240px; + flex: 0 0 240px; + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 12px; + gap: 10px; +} + +.sidebar-header { + display: flex; + flex-direction: column; + line-height: 1.1; +} +.brand { + font-weight: 700; + font-size: 15px; +} +.brand-sub { + color: var(--muted); + font-size: 12px; +} + +.new-vm { + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + padding: 8px; + cursor: pointer; + font-weight: 600; +} +.new-vm:hover { + filter: brightness(1.1); +} + +.actor-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} +.actor-empty, +.sidebar-foot { + color: var(--muted); + font-size: 12px; +} +.sidebar-foot { + border-top: 1px solid var(--border); + padding-top: 8px; + line-height: 1.4; +} + +.actor-item { + position: relative; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 26px 8px 10px; + cursor: pointer; +} +.actor-item:hover { + border-color: #33405a; +} +.actor-active { + border-color: var(--accent); +} +.actor-name { + font-weight: 600; +} +.actor-id { + color: var(--muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.actor-remove { + position: absolute; + top: 6px; + right: 6px; + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.actor-remove:hover { + color: #ff6b6b; +} + +/* Main */ +.main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.no-actor, +.empty-hint { + color: var(--muted); + padding: 24px; +} + +.actor-view { + display: flex; + flex-direction: column; + height: 100%; +} + +.tabbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.tab { + display: flex; + align-items: center; + gap: 8px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 8px; + cursor: pointer; + font-size: 13px; +} +.tab-active { + border-color: var(--accent); +} +.tab-close, +.tab-new { + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + font-size: 15px; + line-height: 1; +} +.tab-new { + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 10px; + font-size: 16px; +} +.tab-new:disabled { + opacity: 0.4; + cursor: default; +} +.tab-close:hover { + color: #ff6b6b; +} +.conn-status { + margin-left: auto; + color: var(--muted); + font-size: 12px; +} + +.error-banner { + background: #3a1113; + color: #ffb4b4; + padding: 6px 10px; + font-size: 12px; + border-bottom: 1px solid #5a1a1e; + white-space: pre-wrap; +} + +.terminals { + position: relative; + flex: 1; + min-height: 0; + background: #0b0e14; +} +.terminal-pane { + position: absolute; + inset: 0; + padding: 6px; +} diff --git a/examples/browser-terminal/src/vite-env.d.ts b/examples/browser-terminal/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/browser-terminal/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/browser-terminal/tsconfig.json b/examples/browser-terminal/tsconfig.json new file mode 100644 index 0000000000..c97ccc68f8 --- /dev/null +++ b/examples/browser-terminal/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false + }, + "include": ["server.ts", "vite.config.ts", "src"] +} diff --git a/examples/browser-terminal/vite.config.ts b/examples/browser-terminal/vite.config.ts new file mode 100644 index 0000000000..5561a5b5b7 --- /dev/null +++ b/examples/browser-terminal/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The RivetKit server (server.ts) listens on :6420. The web app talks to it +// directly from the browser; RivetKit serves permissive CORS for public +// clients, so no proxy is needed. Override the endpoint with +// VITE_AGENTOS_ENDPOINT if you run the server elsewhere. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index 1ba219680e..27aa5e096a 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -1,5 +1,5 @@ /** - * Rust-backed `agentOs(...)` definition. + * Rust-backed `agentOS(...)` definition. * * Produces an `ActorDefinition` whose `nativeFactoryBuilder` constructs a * native-actor-plugin factory through `runtime.createNativePluginFactory(...)` @@ -83,7 +83,7 @@ interface SerializedAgentConfig { /** * A native `host_dir` mount of a host `node_modules` directory at - * `/root/node_modules`, the serializable form `agentOs({ options: { mounts } })` + * `/root/node_modules`, the serializable form `agentOS({ options: { mounts } })` * accepts across the NAPI boundary. */ export interface NodeModulesMountConfig { @@ -318,21 +318,21 @@ export function buildConfigJson( function serializeNativeMounts(input: unknown): NativeMountLike[] | undefined { if (input == null) return undefined; if (!Array.isArray(input)) { - throw new Error("agentOs() options.mounts must be an array"); + throw new Error("agentOS() options.mounts must be an array"); } return input.map((mount, index) => { if (!mount || typeof mount !== "object") { - throw new Error(`agentOs() options.mounts[${index}] must be an object`); + throw new Error(`agentOS() options.mounts[${index}] must be an object`); } const record = mount as Record; if (record.driver !== undefined) { throw new Error( - "agentOs() only supports Native mounts across the NAPI boundary; Plain mounts with driver callbacks are not serializable", + "agentOS() only supports Native mounts across the NAPI boundary; Plain mounts with driver callbacks are not serializable", ); } if (record.filesystem !== undefined) { throw new Error( - "agentOs() only supports Native mounts across the NAPI boundary; Overlay mounts are not serializable", + "agentOS() only supports Native mounts across the NAPI boundary; Overlay mounts are not serializable", ); } const plugin = record.plugin; @@ -343,7 +343,7 @@ function serializeNativeMounts(input: unknown): NativeMountLike[] | undefined { typeof (plugin as Record).id !== "string" ) { throw new Error( - `agentOs() options.mounts[${index}] must be a Native mount with { path, plugin: { id, config? } }`, + `agentOS() options.mounts[${index}] must be a Native mount with { path, plugin: { id, config? } }`, ); } return { @@ -361,16 +361,16 @@ function serializeNativeMounts(input: unknown): NativeMountLike[] | undefined { function serializeSidecar(input: unknown): { pool?: string } | undefined { if (input == null) return undefined; if (!input || typeof input !== "object") { - throw new Error("agentOs() options.sidecar must be an object"); + throw new Error("agentOS() options.sidecar must be an object"); } const record = input as Record; if (record.kind === "explicit" || record.handle !== undefined) { throw new Error( - "agentOs() only supports sidecar shared pool configuration across the NAPI boundary; explicit sidecar handles are not serializable", + "agentOS() only supports sidecar shared pool configuration across the NAPI boundary; explicit sidecar handles are not serializable", ); } if (record.kind !== undefined && record.kind !== "shared") { - throw new Error('agentOs() options.sidecar.kind must be "shared"'); + throw new Error('agentOS() options.sidecar.kind must be "shared"'); } return typeof record.pool === "string" ? { pool: record.pool } : {}; } @@ -381,7 +381,7 @@ function buildNativeFactoryBuilder( return (runtime) => { if (runtime.kind !== "napi") { throw new Error( - `agentOs() is only supported on the native NAPI runtime (current runtime kind: ${runtime.kind})`, + `agentOS() is only supported on the native NAPI runtime (current runtime kind: ${runtime.kind})`, ); } if (!runtime.createNativePluginFactory) { @@ -405,7 +405,7 @@ function buildNativeFactoryBuilder( } /** - * Type alias for the `agentOs(...)` return type. Events are not typed at the TS + * Type alias for the `agentOS(...)` return type. Events are not typed at the TS * surface because the Rust plugin owns the broadcast set, but the ACTIONS are * typed via {@link AgentOsActions} — a TS mirror of the Rust dispatch in * `crates/agentos-actor-plugin/src/actions/mod.rs`. That is what gives @@ -465,7 +465,7 @@ export const DEFAULT_AGENTOS_ACTOR_OPTIONS = { maxQueueMessageSize: ACTOR_NEVER_HIT_MESSAGE_BYTES, } as const; -export function agentOs( +export function createAgentOS( config: AgentOsActorConfigInput, ): AgentOsActorDefinition { const parsed = agentOsActorConfigSchema.parse( diff --git a/packages/agentos/src/config.ts b/packages/agentos/src/config.ts index 532ae06db0..510f26e7fc 100644 --- a/packages/agentos/src/config.ts +++ b/packages/agentos/src/config.ts @@ -40,7 +40,7 @@ export const nativeAgentOsOptionsSchema = z * RivetKit actor lifecycle/transport options forwarded to `actor({ options })`. * * Validated downstream by RivetKit's own actor config schema, so this stays a - * permissive pass-through allow-list. `agentOs()` overlays the never-hit + * permissive pass-through allow-list. `agentOS()` overlays the never-hit * defaults in `DEFAULT_AGENTOS_ACTOR_OPTIONS` (see actor.ts) and lets any value * here win, so callers can still tighten a bound when they want one. */ diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index a77171f568..a70972e28d 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -1,6 +1,6 @@ // Rust-backed agent-os actor surface (native actor plugin / cdylib). // -// Only the `agentOs()` definition function, the config schema, the +// Only the `agentOS()` definition function, the config schema, the // `nodeModulesMount` helper, the plugin-path resolver, and the public domain // types are exported. All actor lifecycle + action dispatch live in the Rust // plugin (`crates/agentos-actor-plugin`), loaded by RivetKit via the generic @@ -13,7 +13,7 @@ import type { } from "@rivet-dev/agentos-core"; import { type AgentOsActorDefinition, - agentOs as createAgentOs, + createAgentOS, } from "./actor.js"; import type { AgentOsActorConfigInput, @@ -94,7 +94,6 @@ export type { VmFetchResponse, WriteFileResult, } from "./actor-actions.js"; -export { createAgentOs as agentOs }; export type AgentOSActorConfigInput = NativeAgentOsOptions & @@ -124,7 +123,7 @@ export function agentOS( ...options } = config; - return createAgentOs({ + return createAgentOS({ options, preview, onBeforeConnect, diff --git a/packages/agentos/tests/actor.test.ts b/packages/agentos/tests/actor.test.ts index 8c5d3b26e7..b5fb197e75 100644 --- a/packages/agentos/tests/actor.test.ts +++ b/packages/agentos/tests/actor.test.ts @@ -15,7 +15,6 @@ import { createClient } from "rivetkit/client"; import { afterEach, beforeAll, describe, expect, test } from "vitest"; import { agentOS, - agentOs, buildConfigJson, getPluginPath, nodeModulesMount, @@ -306,13 +305,11 @@ 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", - loopbackExemptPorts: [4020], - mounts: [nodeModulesMount("/host/project/node_modules")], - sidecar: { kind: "shared", pool: "agentos-smoke" }, - }, + const definition = agentOS({ + additionalInstructions: "stay deterministic", + loopbackExemptPorts: [4020], + mounts: [nodeModulesMount("/host/project/node_modules")], + sidecar: { kind: "shared", pool: "agentos-smoke" }, }); const expectedHandle = Symbol( "native-factory", @@ -377,14 +374,6 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { }); test("rejects native actor options that cannot cross the NAPI config boundary", () => { - expect(() => - agentOs({ - options: { - toolKits: [], - } as never, - }), - ).toThrow(/toolKits/); - expect(() => agentOS({ toolKits: [], @@ -392,34 +381,28 @@ describe("@rivet-dev/agentos native plugin package bridge", () => { ).toThrow(/toolKits/); expect(() => - agentOs({ - options: { - mounts: [{ path: "/data", driver: {} }], - } as never, - }), + agentOS({ + mounts: [{ path: "/data", driver: {} }], + } as never), ).toThrow(/driver/); expect(() => - agentOs({ - options: { - mounts: [ - { - path: "/data", - driver: { - readFile: async () => new Uint8Array(), - }, + agentOS({ + mounts: [ + { + path: "/data", + driver: { + readFile: async () => new Uint8Array(), }, - ], - } as never, - }), + }, + ], + } as never), ).toThrow(/driver/); expect(() => - agentOs({ - options: { - sidecar: { kind: "explicit", handle: {} }, - } as never, - }), + agentOS({ + sidecar: { kind: "explicit", handle: {} }, + } as never), ).toThrow(/sidecar/); }); diff --git a/packages/agentos/tests/fixtures/agentos-runtime-server.ts b/packages/agentos/tests/fixtures/agentos-runtime-server.ts index 4115824f1b..6c2c41c5fc 100644 --- a/packages/agentos/tests/fixtures/agentos-runtime-server.ts +++ b/packages/agentos/tests/fixtures/agentos-runtime-server.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { setup } from "rivetkit"; -import { agentOs } from "../../src/index.js"; +import { agentOS } from "../../src/index.js"; import { buildNativeRegistry } from "../../../../../r6/rivetkit-typescript/packages/rivetkit/src/registry/native"; const fixtureDir = dirname(fileURLToPath(import.meta.url)); @@ -17,19 +17,17 @@ function resolveEngineBinaryPath(): string | undefined { const registry = setup({ use: { - os: agentOs({ - options: { - permissions: { - fs: "allow", - network: "allow", - childProcess: "allow", - process: "allow", - env: "allow", - }, - sidecar: { - kind: "shared", - pool: process.env.AGENTOS_TEST_SIDECAR_POOL, - }, + os: agentOS({ + permissions: { + fs: "allow", + network: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + }, + sidecar: { + kind: "shared", + pool: process.env.AGENTOS_TEST_SIDECAR_POOL, }, }), }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6637fcb6d..700258fa5c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,10 @@ packages: # Nested example packages (e.g. examples/quickstart/hello-world) so every # embeddable example is a workspace member that `turbo check-types` covers. - examples/*/* + # browser-terminal is a standalone example installed from published npm + # packages (own node_modules); keep it out of the workspace so its pinned + # published deps are not linked to workspace source. + - '!examples/browser-terminal' - scripts/publish - website # Local docs theme consumed as a workspace member (symlinked from ~/docs-theme