diff --git a/README.md b/README.md index a4694bc..6914e07 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,20 @@ const container = await createAgentContainer({ await container.start(); const session = await container.createWorkerdSession(); -await session.start(); -const { result } = await session.run({ - code: ` - const pkg = await WORKSPACE.readText("package.json"); - const { stdout } = await EXEC.run({ command: "node", args: ["--version"] }); - return { name: JSON.parse(pkg).name, node: stdout.trim() }; +const { result } = await session.run( + ` + export async function run({ input, WORKSPACE, EXEC }) { + const pkg = await WORKSPACE.readText(input.packagePath); + const { stdout } = await EXEC.run({ command: "node", args: ["--version"] }); + return { name: JSON.parse(pkg).name, node: stdout.trim() }; + } `, -}); + { + language: "ts", + input: { packagePath: "package.json" }, + }, +); console.log(result); // { name: "my-project", node: "v22.0.0" } @@ -103,7 +108,9 @@ Agent Container should not be described as a secure sandbox for fully untrusted It reduces ambient authority by moving access behind bindings, but the host still brokers real filesystem and subprocess operations. `EXEC.run` still starts real host subprocesses. `WORKSPACE` still maps to real files or a copied workspace. The bridge is session-local and token-gated, but it is not a replacement for VM, container, kernel, or production-grade isolation when running adversarial code. -The goal is narrower and more useful for coding agents: do not give generated code broad host authority by default. Give it explicit capabilities that a harness can inspect, constrain, log, and eventually swap for stronger backends. +`workerd` network policy applies to the guest runtime, not to subprocesses started through `EXEC.run`. A permitted command runs as a host subprocess with the configured cwd, environment projection, timeout, and command policy. + +The goal is narrower and more useful for coding agents: do not give generated code broad host authority by default. Give it explicit capabilities that a harness can inspect, constrain, and log. ## How It Works @@ -134,7 +141,7 @@ GUEST (workerd) remove ``` -The current `workerd` harness evaluates JavaScript snippets and passes in binding objects. Those binding methods call a session-local bridge. The bridge validates JSON requests, checks the configured policy through the host controllers, performs the operation, and emits observability events when configured. +The `workerd` harness runs JavaScript or TypeScript modules and passes capability bindings through a `run(ctx)` export. Those binding methods call a session-local bridge. The bridge validates JSON requests, checks the configured policy through the host controllers, performs the operation, and emits observability events when configured. ## Current Surface @@ -152,12 +159,12 @@ Implemented today: - `ENV` exposes public variables and `SECRETS` exposes secret-classified variables. - `OBSERVE.emit` lets guest code add structured events to the host observability sink. - `workerd` outbound fetch is disabled by default and can be enabled with optional origin filtering. +- `session.run()` accepts code or workspace path sources, transpiles TypeScript/TSX per file, and preserves the `workerd` module graph for static relative imports. Not implemented yet (WIP): -- a first-class `GIT` binding - a first-class `NET` binding -- module loading for arbitrary TypeScript projects inside the guest +- narrow workspace change primitives such as `diff`, `statusSummary`, `snapshot`, and `applyPatch` ## Bindings @@ -401,15 +408,10 @@ const session = await container.createWorkerdSession({ allowFetch: false, }); -await session.start(); - -const { result, logs, durationMs } = await session.run({ - code: ` - console.log("inside workerd"); - return await WORKSPACE.readText("README.md"); - `, - timeoutMs: 5_000, -}); +const { result, logs, durationMs } = await session.run( + { path: "scripts/check.ts" }, + { timeoutMs: 5_000 }, +); await session.stop(); ``` diff --git a/apps/e2e/tests/harness-driver.ts b/apps/e2e/tests/harness-driver.ts index 02260b4..d811d1a 100644 --- a/apps/e2e/tests/harness-driver.ts +++ b/apps/e2e/tests/harness-driver.ts @@ -78,7 +78,6 @@ export async function createHarnessDriver( await container.start(); const session = await container.createWorkerdSession(); - await session.start(); return { container, @@ -87,11 +86,14 @@ export async function createHarnessDriver( return events; }, async run(code: string, env?: Record): Promise { - return await session.run({ - code, - env, - timeoutMs: 10_000, - }); + return await session.run( + `export async function run({ WORKSPACE, EXEC, ENV, SECRETS, OBSERVE, env }) {\n${code}\n}`, + { + language: "js", + env, + timeoutMs: 10_000, + }, + ); }, async readSeedFile(path: string): Promise { return await readFile(join(seedWorkspace.root, path), "utf8"); diff --git a/packages/agent-container/package.json b/packages/agent-container/package.json index befcbff..9ae4974 100644 --- a/packages/agent-container/package.json +++ b/packages/agent-container/package.json @@ -19,12 +19,14 @@ "check-types": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@agent-container/types": "workspace:*" + "@agent-container/types": "workspace:*", + "esbuild": "catalog:" }, "devDependencies": { "@agent-container/test-utils": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "workerd": "catalog:" } } diff --git a/packages/agent-container/src/index.ts b/packages/agent-container/src/index.ts index ce1c741..a692fcc 100644 --- a/packages/agent-container/src/index.ts +++ b/packages/agent-container/src/index.ts @@ -6,7 +6,7 @@ export { export { resolveEnv } from "./env.js"; export { LocalCapabilityBridgeServer } from "./bridge.js"; export { LocalExecController } from "./exec.js"; -export { createWorkerdSession, LocalWorkerdSession } from "./workerd/index.js"; +export { createWorkerdSession, LocalWorkerdSession, WorkerdRunError } from "./workerd/index.js"; export { LocalWorkspaceController } from "./workspace.js"; export type { @@ -29,10 +29,13 @@ export type { ResolvedEnv, ResolvedEnvEntry, ResolvedEnvSnapshot, + WorkerdRunInput, + WorkerdRunErrorDetails, WorkerdRunOptions, WorkerdRunResult, WorkerdSession, WorkerdSessionOptions, + WorkerdSourceLanguage, WorkspaceController, WorkspaceEntry, WorkspaceEntryKind, diff --git a/packages/agent-container/src/workerd/config.ts b/packages/agent-container/src/workerd/config.ts index f0c9271..b9c7341 100644 --- a/packages/agent-container/src/workerd/config.ts +++ b/packages/agent-container/src/workerd/config.ts @@ -2,6 +2,16 @@ import type { WorkerdSessionOptions } from "@agent-container/types"; export const DEFAULT_COMPATIBILITY_DATE = "2026-04-20"; +export interface WorkerdConfigModule { + name: string; + fileName: string; + kind: "esModule" | "json" | "text" | "wasm"; +} + +export interface WorkerdConfigOptions extends WorkerdSessionOptions { + modules: readonly WorkerdConfigModule[]; +} + function escapeCapnpString(value: string): string { return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\n/gu, "\\n"); } @@ -67,15 +77,34 @@ export function buildConfig( port: number, bridgePort: number, bridgeToken: string, - options: WorkerdSessionOptions, + options: WorkerdConfigOptions, ): string { const compatibilityDate = escapeCapnpString( options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE, ); + const compatibilityFlags = + options.compatibilityFlags === undefined || options.compatibilityFlags.length === 0 + ? "" + : `\n compatibilityFlags = [${options.compatibilityFlags + .map((flag) => `"${escapeCapnpString(flag)}"`) + .join(", ")}],`; const { services, globalOutbound, extraWorkers } = buildOutboundServices({ allowFetch: options.allowFetch ?? false, allowedFetchOrigins: [...(options.allowedFetchOrigins ?? [])], }); + const modules = options.modules + .map((module) => { + const field = + module.kind === "esModule" + ? "esModule" + : module.kind === "json" + ? "json" + : module.kind === "text" + ? "text" + : "wasm"; + return ` ( name = "${escapeCapnpString(module.name)}", ${field} = embed "${escapeCapnpString(module.fileName)}" ),`; + }) + .join("\n"); return `using Workerd = import "/workerd/workerd.capnp"; @@ -92,11 +121,10 @@ const config :Workerd.Config = ( const mainWorker :Workerd.Worker = ( modules = [ - ( name = "worker", esModule = embed "worker.js" ), +${modules} ], - compatibilityDate = "${compatibilityDate}", + compatibilityDate = "${compatibilityDate}",${compatibilityFlags} bindings = [ - ( name = "UNSAFE_EVAL", unsafeEval = void ), ( name = "CAPABILITY_BRIDGE", service = "capabilityBridge" ), ( name = "CAPABILITY_BRIDGE_TOKEN", text = "${escapeCapnpString(bridgeToken)}" ), ], diff --git a/packages/agent-container/src/workerd/harness.ts b/packages/agent-container/src/workerd/harness.ts index edf1b2c..602e300 100644 --- a/packages/agent-container/src/workerd/harness.ts +++ b/packages/agent-container/src/workerd/harness.ts @@ -1,5 +1,20 @@ -export function workerHarnessSource(): string { - return `function formatValue(value) { +function escapeJsString(value: string): string { + return JSON.stringify(value); +} + +function relativeEntrySpecifier(entryModuleName: string): string { + return entryModuleName.startsWith(".") ? entryModuleName : `./${entryModuleName}`; +} + +export function workerHarnessSource(entryModuleName: string | undefined): string { + const importLine = + entryModuleName === undefined + ? "const entryModule = {};" + : `import * as entryModule from ${escapeJsString(relativeEntrySpecifier(entryModuleName))};`; + + return `${importLine} + +function formatValue(value) { if (typeof value === "string") { return value; } @@ -28,6 +43,20 @@ function createLogger(logs) { }; } +function serializeError(error) { + if (error instanceof Error) { + return { + name: typeof error.name === "string" && error.name !== "" ? error.name : undefined, + message: typeof error.message === "string" ? error.message : String(error), + stack: typeof error.stack === "string" ? error.stack : undefined, + }; + } + + return { + message: formatValue(error), + }; +} + function createBridgeBindings(env) { async function call(path, payload) { const response = await env.CAPABILITY_BRIDGE.fetch( @@ -111,32 +140,37 @@ function createBridgeBindings(env) { }; } -async function runCode(code, userEnv, bindings, logs, unsafeEval) { - const logger = createLogger(logs); - const state = globalThis.__agentContainerState ?? (globalThis.__agentContainerState = {}); - if (unsafeEval === undefined || typeof unsafeEval.eval !== "function") { - throw new Error("UnsafeEval binding is not available."); - } +async function runModule(body, env, logs) { + const exportName = typeof body.exportName === "string" ? body.exportName : "run"; + const candidate = + exportName in entryModule + ? entryModule[exportName] + : exportName === "run" + ? entryModule.default + : undefined; - const fn = unsafeEval.eval( - '(async function (env, console, WORKSPACE, EXEC, ENV, SECRETS, OBSERVE, STATE) {"use strict";\\n' + - code + - '\\n})', - ); - if (typeof fn !== "function") { - throw new Error("UnsafeEval did not produce an executable function."); + if (typeof candidate !== "function") { + throw new Error( + exportName === "run" + ? "Module must export a run(ctx) function or a default function." + : "Module export is not a function: " + exportName, + ); } - return await fn( - userEnv ?? {}, - logger, - bindings.WORKSPACE, - bindings.EXEC, - bindings.ENV, - bindings.SECRETS, - bindings.OBSERVE, - state, - ); + const bindings = createBridgeBindings(env); + const logger = createLogger(logs); + const previousConsole = globalThis.console; + globalThis.console = logger; + try { + return await candidate({ + ...bindings, + env: body.userEnv ?? {}, + input: body.input, + console: logger, + }); + } finally { + globalThis.console = previousConsole; + } } export default { @@ -153,19 +187,12 @@ export default { const body = await request.json(); const logs = []; - const bindings = createBridgeBindings(env); try { - const result = await runCode( - String(body.code ?? ""), - body.userEnv ?? {}, - bindings, - logs, - env.UNSAFE_EVAL, - ); + const result = await runModule(body, env, logs); return Response.json({ result, logs }); } catch (error) { return Response.json( - { error: formatValue(error instanceof Error ? error.message : error), logs }, + { error: serializeError(error), logs }, { status: 500 }, ); } diff --git a/packages/agent-container/src/workerd/index.ts b/packages/agent-container/src/workerd/index.ts index d8cdb11..c038270 100644 --- a/packages/agent-container/src/workerd/index.ts +++ b/packages/agent-container/src/workerd/index.ts @@ -1,11 +1,11 @@ import type { ObservabilityEvent, WorkerdSession, WorkerdSessionOptions } from "@agent-container/types"; import type { SessionCapabilityContext } from "../bridge.js"; -import { LocalWorkerdSession } from "./session.js"; +import { LocalWorkerdSession, WorkerdRunError } from "./session.js"; type EmitEvent = (event: Omit) => Promise; -export { LocalWorkerdSession }; +export { LocalWorkerdSession, WorkerdRunError }; export async function createWorkerdSession( options: WorkerdSessionOptions = {}, diff --git a/packages/agent-container/src/workerd/module-graph.ts b/packages/agent-container/src/workerd/module-graph.ts new file mode 100644 index 0000000..d8acbfd --- /dev/null +++ b/packages/agent-container/src/workerd/module-graph.ts @@ -0,0 +1,335 @@ +import { dirname, extname, posix, relative, sep } from "node:path"; + +import { transform } from "esbuild"; + +import type { + WorkerdRunInput, + WorkerdRunOptions, + WorkerdSourceLanguage, + WorkspaceController, +} from "@agent-container/types"; + +export type WorkerdModuleKind = "esModule" | "json" | "text" | "wasm"; + +export interface PreparedWorkerdModule { + name: string; + fileName: string; + kind: WorkerdModuleKind; + content: string | Uint8Array; +} + +export interface PreparedWorkerdRun { + entryModuleName: string; + modules: readonly PreparedWorkerdModule[]; +} + +interface ModuleSource { + name: string; + content: string | Uint8Array; + language: WorkerdSourceLanguage | "json" | "text" | "wasm"; +} + +interface StaticImport { + specifier: string; +} + +const supportedStaticModuleExtensions = [ + ".ts", + ".tsx", + ".js", + ".mjs", + ".json", + ".txt", + ".wasm", +] as const; + +function normalizeModuleName(path: string): string { + return path.replace(/\\/gu, "/").replace(/^\.\/+/u, ""); +} + +function moduleFileName(name: string): string { + return `modules/${name.replace(/[^A-Za-z0-9._-]/gu, "_")}`; +} + +function isRelativeSpecifier(value: string): boolean { + return value.startsWith("./") || value.startsWith("../"); +} + +function languageFromName(name: string): ModuleSource["language"] { + const extension = extname(name).toLowerCase(); + if (extension === ".ts") { + return "ts"; + } + if (extension === ".tsx") { + return "tsx"; + } + if (extension === ".json") { + return "json"; + } + if (extension === ".txt") { + return "text"; + } + if (extension === ".wasm") { + return "wasm"; + } + return "js"; +} + +function extractStaticImports(source: string): readonly StaticImport[] { + const imports: StaticImport[] = []; + const staticImportPattern = + /\b(?:import|export)\s+(?:[^"'()]*?\s+from\s+)?["']([^"']+)["']/gu; + for (const match of source.matchAll(staticImportPattern)) { + const statement = match[0]; + if (/^\s*(?:import|export)\s+type\b/u.test(statement)) { + continue; + } + + const specifier = match[1]; + if (specifier !== undefined) { + imports.push({ specifier }); + } + } + return imports; +} + +function assertNoUnsupportedImports(source: string, moduleName: string): void { + const dynamicImportPattern = /\bimport\s*\(\s*["'][^"']+["']\s*\)/u; + if (dynamicImportPattern.test(source)) { + throw new Error(`Dynamic imports are not supported in workerd module runs: ${moduleName}`); + } + + for (const { specifier } of extractStaticImports(source)) { + if (!isRelativeSpecifier(specifier)) { + throw new Error( + `Only static relative imports are supported in workerd module runs: ${specifier}`, + ); + } + } +} + +function resolveModuleSpecifier(importerName: string, specifier: string): string { + const importerDir = dirname(importerName).replace(/\\/gu, "/"); + return normalizeModuleName(posix.normalize(posix.join(importerDir, specifier))); +} + +async function resolveWorkspaceModule(options: { + workspace: WorkspaceController; + importerName: string; + specifier: string; +}): Promise { + const baseName = resolveModuleSpecifier(options.importerName, options.specifier); + const extension = extname(baseName); + const candidates = + extension === "" + ? supportedStaticModuleExtensions.map( + (candidateExtension) => `${baseName}${candidateExtension}`, + ) + : extension === ".js" + ? [ + baseName, + `${baseName.slice(0, -extension.length)}.ts`, + `${baseName.slice(0, -extension.length)}.tsx`, + ] + : [baseName]; + + for (const candidate of candidates) { + try { + const entry = await options.workspace.stat(candidate); + if (entry.kind === "file") { + return normalizeModuleName(candidate); + } + } catch { + // Try the next candidate. + } + } + + throw new Error(`Could not resolve module import ${options.specifier} from ${options.importerName}`); +} + +function rewriteSpecifier(source: string, from: string, to: string): string { + const escaped = from.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const importSpecifierPattern = new RegExp( + `(\\b(?:import|export)\\s+(?:[^"'()]*?\\s+from\\s+)?)(["'])${escaped}\\2`, + "gu", + ); + return source.replace( + importSpecifierPattern, + (_match, prefix: string, quote: string) => `${prefix}${quote}${to}${quote}`, + ); +} + +function relativeSpecifier(fromModuleName: string, toModuleName: string): string { + const relativePath = posix.relative(posix.dirname(fromModuleName), toModuleName); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +async function transformModuleSource(options: { + module: ModuleSource; + importRewrites: ReadonlyMap; +}): Promise { + const kind: WorkerdModuleKind = + options.module.language === "json" + ? "json" + : options.module.language === "text" + ? "text" + : options.module.language === "wasm" + ? "wasm" + : "esModule"; + + let content = options.module.content; + if (options.module.language === "wasm") { + return { + name: options.module.name, + fileName: moduleFileName(options.module.name), + kind, + content, + }; + } + + if (typeof content !== "string") { + throw new Error(`Expected text module content for ${options.module.name}.`); + } + + for (const [from, to] of options.importRewrites) { + content = rewriteSpecifier(content, from, to); + } + + if (options.module.language === "ts" || options.module.language === "tsx") { + const result = await transform(content, { + format: "esm", + jsx: "transform", + jsxFactory: "h", + jsxFragment: "Fragment", + loader: options.module.language, + sourcemap: "inline", + sourcefile: options.module.name, + target: "es2022", + }); + content = result.code; + } + + return { + name: options.module.name, + fileName: moduleFileName(options.module.name), + kind, + content, + }; +} + +async function createEntrySource( + input: WorkerdRunInput, + options: WorkerdRunOptions | undefined, + workspace: WorkspaceController | undefined, +): Promise { + if (typeof input === "string") { + const language = options?.language ?? "js"; + const name = normalizeModuleName(options?.name ?? `entry.${language}`); + return { + name, + content: input, + language, + }; + } + + if (workspace === undefined) { + throw new Error("Path sources require a workspace capability."); + } + + const name = normalizeModuleName(input.path); + const physicalPath = await workspace.resolvePath(name); + const rootRelativePath = relative(workspace.root, physicalPath); + if (rootRelativePath.startsWith("..") || rootRelativePath.startsWith(`..${sep}`)) { + throw new Error(`Path escapes workspace root: ${input.path}`); + } + const language = languageFromName(name); + + return { + name, + content: language === "wasm" ? await workspace.read(name) : await workspace.readText(name), + language, + }; +} + +export async function prepareWorkerdRun(options: { + input: WorkerdRunInput; + options?: WorkerdRunOptions; + workspace?: WorkspaceController; +}): Promise { + const entry = await createEntrySource(options.input, options.options, options.workspace); + const modules = new Map(); + const importRewritesByModule = new Map>(); + const pending: ModuleSource[] = [entry]; + + while (pending.length > 0) { + const current = pending.shift(); + if (current === undefined || modules.has(current.name)) { + continue; + } + + if ( + typeof current.content === "string" && + current.language !== "json" && + current.language !== "text" + ) { + assertNoUnsupportedImports(current.content, current.name); + } + + modules.set(current.name, current); + const rewrites = new Map(); + importRewritesByModule.set(current.name, rewrites); + + if ( + current.language === "json" || + current.language === "text" || + current.language === "wasm" + ) { + continue; + } + + if (typeof current.content !== "string") { + continue; + } + + for (const { specifier } of extractStaticImports(current.content)) { + if (typeof options.input === "string") { + throw new Error("Relative imports are only supported for workspace path sources."); + } + if (options.workspace === undefined) { + throw new Error("Relative imports require a workspace capability."); + } + + const resolvedName = await resolveWorkspaceModule({ + workspace: options.workspace, + importerName: current.name, + specifier, + }); + rewrites.set(specifier, relativeSpecifier(current.name, resolvedName)); + if (!modules.has(resolvedName)) { + const language = languageFromName(resolvedName); + pending.push({ + name: resolvedName, + content: + language === "wasm" + ? await options.workspace.read(resolvedName) + : await options.workspace.readText(resolvedName), + language, + }); + } + } + } + + const preparedModules = await Promise.all( + [...modules.values()].map((module) => + transformModuleSource({ + module, + importRewrites: importRewritesByModule.get(module.name) ?? new Map(), + }), + ), + ); + + return { + entryModuleName: entry.name, + modules: preparedModules, + }; +} diff --git a/packages/agent-container/src/workerd/session.ts b/packages/agent-container/src/workerd/session.ts index acf984f..56a5bc6 100644 --- a/packages/agent-container/src/workerd/session.ts +++ b/packages/agent-container/src/workerd/session.ts @@ -1,11 +1,13 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { Readable } from "node:stream"; import type { ObservabilityEvent, + WorkerdRunErrorDetails, + WorkerdRunInput, WorkerdRunOptions, WorkerdRunResult, WorkerdSession, @@ -16,13 +18,50 @@ import { LocalCapabilityBridgeServer, type SessionCapabilityContext } from "../b import { findFreePort, findWorkerdBinary } from "./binary.js"; import { buildConfig } from "./config.js"; import { workerHarnessSource } from "./harness.js"; +import { prepareWorkerdRun, type PreparedWorkerdRun } from "./module-graph.js"; type EmitEvent = (event: Omit) => Promise; interface ParsedRunResponse { result: unknown; logs: readonly string[]; - error?: string; + error?: WorkerdRunErrorDetails; +} + +function parseRunErrorDetails(value: unknown): WorkerdRunErrorDetails { + if (typeof value === "string") { + return { message: value }; + } + + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("workerd returned an invalid error response."); + } + + if (!("message" in value) || typeof value.message !== "string") { + throw new Error("workerd returned an invalid error response."); + } + + const details: WorkerdRunErrorDetails = { + message: value.message, + }; + + if ("name" in value && value.name !== undefined) { + if (typeof value.name !== "string") { + throw new Error("workerd returned an invalid error response."); + } + + details.name = value.name; + } + + if ("stack" in value && value.stack !== undefined) { + if (typeof value.stack !== "string") { + throw new Error("workerd returned an invalid error response."); + } + + details.stack = value.stack; + } + + return details; } function parseRunResponse(payload: unknown): ParsedRunResponse { @@ -41,18 +80,30 @@ function parseRunResponse(payload: unknown): ParsedRunResponse { logs = payload.logs; } - let error: string | undefined; + let error: WorkerdRunErrorDetails | undefined; if ("error" in payload && payload.error !== undefined) { - if (typeof payload.error !== "string") { - throw new Error("workerd returned an invalid error response."); - } - - error = payload.error; + error = parseRunErrorDetails(payload.error); } return { result, logs, error }; } +export class WorkerdRunError extends Error { + public readonly guestName: string | undefined; + + public readonly guestStack: string | undefined; + + public readonly logs: readonly string[]; + + public constructor(details: WorkerdRunErrorDetails, logs: readonly string[]) { + super(details.message); + this.name = "WorkerdRunError"; + this.guestName = details.name; + this.guestStack = details.stack; + this.logs = logs; + } +} + async function waitForReady(options: { port: number; timeoutMs: number; @@ -153,7 +204,7 @@ async function terminateProcess( } export class LocalWorkerdSession implements WorkerdSession { - public readonly port: number; + public port: number; readonly #options: WorkerdSessionOptions; @@ -173,6 +224,8 @@ export class LocalWorkerdSession implements WorkerdSession { #bridge: LocalCapabilityBridgeServer | undefined; + #runQueue: Promise = Promise.resolve(); + private constructor( port: number, options: WorkerdSessionOptions, @@ -198,21 +251,45 @@ export class LocalWorkerdSession implements WorkerdSession { } public async start(): Promise { + await this.#startWithPreparedRun(undefined); + } + + async #startWithPreparedRun(preparedRun: PreparedWorkerdRun | undefined): Promise { if (this.#status === "started") { return; } const workerdBinary = await findWorkerdBinary(this.#options.workerdBinary); + this.port = await findFreePort(); const tempDir = await mkdtemp(join(tmpdir(), "agent-container-workerd-")); this.#tempDir = tempDir; try { this.#bridge = await LocalCapabilityBridgeServer.create(this.#context, this.#emit); - await writeFile(join(tempDir, "worker.js"), workerHarnessSource(), "utf8"); + const runnerModule = { + name: "worker.js", + fileName: "worker.js", + kind: "esModule" as const, + content: workerHarnessSource(preparedRun?.entryModuleName), + }; + const modules = [runnerModule, ...(preparedRun?.modules ?? [])]; + + for (const module of modules) { + const filePath = join(tempDir, module.fileName); + await mkdir(dirname(filePath), { recursive: true }); + if (typeof module.content === "string") { + await writeFile(filePath, module.content, "utf8"); + } else { + await writeFile(filePath, module.content); + } + } await writeFile( join(tempDir, "config.capnp"), - buildConfig(this.port, this.#bridge.port, this.#bridge.token, this.#options), + buildConfig(this.port, this.#bridge.port, this.#bridge.token, { + ...this.#options, + modules, + }), "utf8", ); @@ -257,12 +334,35 @@ export class LocalWorkerdSession implements WorkerdSession { } } - public async run(options: WorkerdRunOptions): Promise { - if ((options.language ?? "js") !== "js") { - throw new Error("Only JavaScript execution is currently supported."); + public async run( + input: WorkerdRunInput, + options: WorkerdRunOptions = {}, + ): Promise { + const previousRun = this.#runQueue; + let releaseRun = (): void => {}; + this.#runQueue = new Promise((resolve) => { + releaseRun = resolve; + }); + + await previousRun; + try { + return await this.#runUnlocked(input, options); + } finally { + releaseRun(); } + } - await this.start(); + async #runUnlocked( + input: WorkerdRunInput, + options: WorkerdRunOptions, + ): Promise { + const preparedRun = await prepareWorkerdRun({ + input, + options, + workspace: this.#context.workspace, + }); + await this.stop(); + await this.#startWithPreparedRun(preparedRun); const startedAt = performance.now(); let response: Response; @@ -271,8 +371,9 @@ export class LocalWorkerdSession implements WorkerdSession { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - code: options.code, userEnv: options.env ?? {}, + input: options.input, + exportName: options.exportName, }), signal: AbortSignal.timeout(options.timeoutMs ?? 5_000), }); @@ -307,9 +408,9 @@ export class LocalWorkerdSession implements WorkerdSession { scope: "container", action: "workerd.run", outcome: "error", - detail: body.error, + detail: body.error.message, }); - throw new Error(body.error); + throw new WorkerdRunError(body.error, body.logs); } if (!response.ok) { diff --git a/packages/agent-container/tests/workerd-config.unit.test.ts b/packages/agent-container/tests/workerd-config.unit.test.ts new file mode 100644 index 0000000..73aceeb --- /dev/null +++ b/packages/agent-container/tests/workerd-config.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { buildConfig } from "../src/workerd/config.js"; + +describe("workerd config", () => { + it("emits compatibility flags and native module kinds", () => { + const config = buildConfig(1000, 1001, "token", { + compatibilityFlags: ["nodejs_compat"], + modules: [ + { name: "worker.js", fileName: "worker.js", kind: "esModule" }, + { name: "data.json", fileName: "data.json", kind: "json" }, + { name: "message.txt", fileName: "message.txt", kind: "text" }, + { name: "add.wasm", fileName: "add.wasm", kind: "wasm" }, + ], + }); + + expect(config).toContain('compatibilityFlags = ["nodejs_compat"]'); + expect(config).toContain('( name = "worker.js", esModule = embed "worker.js" )'); + expect(config).toContain('( name = "data.json", json = embed "data.json" )'); + expect(config).toContain('( name = "message.txt", text = embed "message.txt" )'); + expect(config).toContain('( name = "add.wasm", wasm = embed "add.wasm" )'); + }); +}); diff --git a/packages/agent-container/tests/workerd.integration.test.ts b/packages/agent-container/tests/workerd.integration.test.ts new file mode 100644 index 0000000..7ec8900 --- /dev/null +++ b/packages/agent-container/tests/workerd.integration.test.ts @@ -0,0 +1,245 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createAgentContainer, WorkerdRunError } from "agent-container"; +import { createTempWorkspace, writeWorkspaceFiles, type TempWorkspace } from "@agent-container/test-utils"; + +interface WorkerdResource { + workspace: TempWorkspace; + container?: Awaited>; +} + +const resources = new Set(); + +const addWasmModule = Uint8Array.from( + Buffer.from("0061736d0100000001070160027f7f017f030201000707010361646400000a09010700200020016a0b", "hex"), +); + +async function disposeWorkerdResource(resource: WorkerdResource): Promise { + if (resource.container !== undefined) { + await resource.container.stop(); + } + + await resource.workspace.dispose(); +} + +afterEach(async () => { + const trackedResources = [...resources]; + resources.clear(); + await Promise.allSettled(trackedResources.map(disposeWorkerdResource)); +}); + +async function createTestContainer(files: Record): Promise<{ + container: Awaited>; + workspace: TempWorkspace; +}> { + const workspace = await createTempWorkspace("agent-container-workerd-"); + await writeWorkspaceFiles(workspace.root, files); + const container = await createAgentContainer({ + workspace: { + root: workspace.root, + mode: "live", + }, + env: { + include: ["PUBLIC_*"], + processEnv: "none", + }, + exec: { + allowedCommands: ["node"], + }, + network: { + allowFetch: false, + }, + }); + resources.add({ workspace, container }); + await container.start(); + return { container, workspace }; +} + +describe("workerd module execution", () => { + it("runs JavaScript, TypeScript, and TSX code sources through the export contract", async () => { + const { container } = await createTestContainer({}); + const session = await container.createWorkerdSession(); + + await expect( + session.run("export function run() { return 'js-ok'; }", { language: "js" }), + ).resolves.toMatchObject({ result: "js-ok" }); + + await expect( + session.run("export function run(): number { const value: number = 42; return value; }", { + language: "ts", + }), + ).resolves.toMatchObject({ result: 42 }); + + await expect( + session.run( + ` + function h(type: string): { type: string } { + return { type }; + } + + export function run(): string { + const value = tsx-ok; + return value.type; + } + `, + { + language: "tsx", + }, + ), + ).resolves.toMatchObject({ result: "span" }); + }); + + it("runs workspace path sources with preserved relative module imports", async () => { + const { container } = await createTestContainer({ + "src/data.json": "{\"name\":\"agent-container\"}\n", + "src/message.txt": "hello-text", + "src/util.ts": "export function label(value: string): string { return `label:${value}`; }\n", + "src/task.ts": ` + import data from "./data.json"; + import message from "./message.txt"; + import { label } from "./util"; + + export function run({ env }: { env: Record }) { + return { + name: data.name, + message, + label: label(env.value), + }; + } + `, + }); + const session = await container.createWorkerdSession(); + + await expect( + session.run({ path: "src/task.ts" }, { + env: { value: "ok" }, + }), + ).resolves.toMatchObject({ + result: { + name: "agent-container", + message: "hello-text", + label: "label:ok", + }, + }); + }); + + it("passes structured invocation input to module runs", async () => { + const { container } = await createTestContainer({ + "README.md": "structured input\n", + "src/task.ts": ` + export async function run({ input, WORKSPACE }: { input: { path: string }, WORKSPACE: { readText(path: string): Promise } }) { + return await WORKSPACE.readText(input.path); + } + `, + }); + const session = await container.createWorkerdSession(); + + await expect( + session.run( + { path: "src/task.ts" }, + { + input: { path: "README.md" }, + }, + ), + ).resolves.toMatchObject({ result: "structured input\n" }); + }); + + it("supports custom export names and capability bindings through ctx", async () => { + const { container } = await createTestContainer({ + ".env": "PUBLIC_MODE=demo\n", + "README.md": "hello workspace\n", + "src/task.ts": ` + export async function inspect({ WORKSPACE, ENV, OBSERVE }) { + await OBSERVE.emit({ scope: "workspace", action: "inspect", outcome: "success" }); + return { + readme: await WORKSPACE.readText("README.md"), + mode: await ENV.get("PUBLIC_MODE"), + }; + } + `, + }); + const session = await container.createWorkerdSession(); + + await expect( + session.run({ path: "src/task.ts" }, { + exportName: "inspect", + }), + ).resolves.toMatchObject({ + result: { + readme: "hello workspace\n", + mode: "demo", + }, + }); + }); + + it("rejects unsupported imports and missing callable exports with clear errors", async () => { + const { container } = await createTestContainer({ + "src/bare.ts": "import leftPad from 'left-pad'; export function run() { return leftPad; }", + "src/dynamic.ts": "export async function run() { return await import('./other.js'); }", + "src/value.ts": "export const value = 1;", + }); + const session = await container.createWorkerdSession(); + + await expect( + session.run({ path: "src/bare.ts" }), + ).rejects.toThrow("Only static relative imports are supported"); + await expect( + session.run({ path: "src/dynamic.ts" }), + ).rejects.toThrow("Dynamic imports are not supported"); + await expect( + session.run({ path: "src/value.ts" }), + ).rejects.toThrow("Module must export a run(ctx) function or a default function."); + }); + + it("imports native wasm modules through workerd", async () => { + const { container, workspace } = await createTestContainer({ + "src/task.ts": ` + import addModule from "./add.wasm"; + + export async function run() { + const instance = await WebAssembly.instantiate(addModule); + const add = instance.exports.add as (left: number, right: number) => number; + return add(2, 3); + } + `, + }); + await writeFile(join(workspace.root, "src/add.wasm"), addWasmModule); + const session = await container.createWorkerdSession(); + + await expect(session.run({ path: "src/task.ts" })).resolves.toMatchObject({ result: 5 }); + }); + + it("rejects path source escapes before module generation", async () => { + const { container } = await createTestContainer({}); + const session = await container.createWorkerdSession(); + + await expect(session.run({ path: "../outside.ts" })).rejects.toThrow("Path escapes workspace root"); + }); + + it("preserves guest error name, stack, and logs", async () => { + const { container } = await createTestContainer({}); + const session = await container.createWorkerdSession(); + + let caught: unknown; + try { + await session.run(` + export function run({ console }) { + console.log("before failure"); + throw new TypeError("bad input"); + } + `); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(WorkerdRunError); + const error = caught as WorkerdRunError; + expect(error.message).toBe("bad input"); + expect(error.guestName).toBe("TypeError"); + expect(error.guestStack).toContain("TypeError: bad input"); + expect(error.logs).toEqual(["before failure"]); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 23720ec..f31942d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,7 +6,7 @@ export type ProcessEnvMode = "none" | "allow-matching" | "all"; export type EnvClassification = "public" | "secret"; -export type WorkerdLanguage = "js"; +export type WorkerdSourceLanguage = "js" | "ts" | "tsx"; export interface FileEnvSource { type: "file"; @@ -153,14 +153,19 @@ export interface WorkerdSessionOptions { allowedFetchOrigins?: readonly string[]; startupTimeoutMs?: number; compatibilityDate?: string; + compatibilityFlags?: readonly string[]; workerdBinary?: string; } +export type WorkerdRunInput = string | { path: string }; + export interface WorkerdRunOptions { - code: string; - language?: WorkerdLanguage; + language?: WorkerdSourceLanguage; + name?: string; + exportName?: string; timeoutMs?: number; env?: Record; + input?: unknown; } export interface WorkerdRunResult { @@ -169,11 +174,17 @@ export interface WorkerdRunResult { durationMs: number; } +export interface WorkerdRunErrorDetails { + name?: string; + message: string; + stack?: string; +} + export interface WorkerdSession { readonly port: number; readonly status: "created" | "started" | "stopped"; start(): Promise; - run(options: WorkerdRunOptions): Promise; + run(input: WorkerdRunInput, options?: WorkerdRunOptions): Promise; stop(): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1eb01c7..140af02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@types/node': specifier: ^22.13.14 version: 22.19.17 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 oxfmt: specifier: ^0.26.0 version: 0.26.0 @@ -195,6 +198,9 @@ importers: '@agent-container/types': specifier: workspace:* version: link:../types + esbuild: + specifier: 'catalog:' + version: 0.28.0 devDependencies: '@agent-container/test-utils': specifier: workspace:* @@ -208,6 +214,9 @@ importers: vitest: specifier: 'catalog:' version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + workerd: + specifier: 'catalog:' + version: 1.20260422.1 packages/cli: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7721e05..d0a9025 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - apps/* - packages/* catalog: + esbuild: ^0.28.0 zod: ^4.1.13 typescript: ^6 "@types/node": ^22.13.14