Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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();
```
Expand Down
14 changes: 8 additions & 6 deletions apps/e2e/tests/harness-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export async function createHarnessDriver(

await container.start();
const session = await container.createWorkerdSession();
await session.start();

return {
container,
Expand All @@ -87,11 +86,14 @@ export async function createHarnessDriver(
return events;
},
async run(code: string, env?: Record<string, string>): Promise<WorkerdRunResult> {
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<string> {
return await readFile(join(seedWorkspace.root, path), "utf8");
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
}
}
5 changes: 4 additions & 1 deletion packages/agent-container/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,10 +29,13 @@ export type {
ResolvedEnv,
ResolvedEnvEntry,
ResolvedEnvSnapshot,
WorkerdRunInput,
WorkerdRunErrorDetails,
WorkerdRunOptions,
WorkerdRunResult,
WorkerdSession,
WorkerdSessionOptions,
WorkerdSourceLanguage,
WorkspaceController,
WorkspaceEntry,
WorkspaceEntryKind,
Expand Down
36 changes: 32 additions & 4 deletions packages/agent-container/src/workerd/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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";

Expand All @@ -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)}" ),
],
Expand Down
95 changes: 61 additions & 34 deletions packages/agent-container/src/workerd/harness.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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 },
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-container/src/workerd/index.ts
Original file line number Diff line number Diff line change
@@ -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<ObservabilityEvent, "timestamp">) => Promise<void>;

export { LocalWorkerdSession };
export { LocalWorkerdSession, WorkerdRunError };

export async function createWorkerdSession(
options: WorkerdSessionOptions = {},
Expand Down
Loading
Loading