Skip to content

Commit 8dd0e89

Browse files
kapaleshreyasclaude
andcommitted
feat(server): per-request runtime selection via substrates registry
ComputerAgentServer was previously hardcoded to one substrate per server deployment (the constructor's `substrate: () => ...`). Now accepts a registry of substrate factories keyed by wire-side name, and clients pick which to use per-request via a `runtime` field in /run's body: new ComputerAgentServer({ port: 8787, substrates: { local: () => new LocalSubstrate(), bwrap: () => new BwrapSubstrate({ extraRoBinds: [...] }), e2b: () => new E2BSubstrate({ apiKey: ... }), }, defaultRuntime: "bwrap", }); Request body: { "source": "github.com/...", "harness": "claude-agent-sdk", "runtime": "bwrap", "message": "..." } Resolution precedence: body.runtime -> defaultRuntime -> first registered key -> legacy singular substrate -> fresh LocalSubstrate (zero-config). Unknown runtime returns 400 UNKNOWN_RUNTIME with the available list, so the client gets actionable feedback instead of a silent fallback. /health endpoint now advertises: { ok: true, activeRuns, max, runtimes: [...], defaultRuntime: "..." } Backward compat: the v0 singular `substrate?: () => Substrate` option still works (marked @deprecated; treated as a one-substrate deployment). Verified: typecheck clean, all three constructor shapes instantiate (multi-runtime / legacy singular / zero-config), /health lists registered runtimes, /run returns 400 with available list on unknown runtime. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4a3b012 commit 8dd0e89

1 file changed

Lines changed: 118 additions & 7 deletions

File tree

examples/computeragent-server.ts

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,33 @@ export interface ComputerAgentServerOptions {
4747
*/
4848
readonly defaultEnvs?: Readonly<Record<string, string>>;
4949
/**
50-
* Substrate factory — if absent, every request boots a fresh LocalSubstrate.
51-
* Override to swap in `BwrapSubstrate` for namespace-isolated agents, an
52-
* E2B template, a long-lived VM, or a test mock. Any `Substrate` works.
50+
* Registry of substrate factories keyed by wire-side `runtime` name.
51+
* Clients pick which to use per-request via the `runtime` field in the
52+
* /run body. Built-ins shipped with examples:
53+
* - "local" → LocalSubstrate (process boundary, no security boundary)
54+
* - "bwrap" → BwrapSubstrate (Linux namespace isolation, recommended)
55+
* - "e2b" → E2BSubstrate (Firecracker VMs, external service)
56+
*
57+
* Register the ones your deployment supports:
58+
*
59+
* substrates: {
60+
* local: () => new LocalSubstrate(),
61+
* bwrap: () => new BwrapSubstrate({ extraRoBinds: [...] }),
62+
* }
63+
*
64+
* If only one is registered, it's used regardless of what the client asks for.
65+
* If both `substrates` and the legacy `substrate` are absent, requests use a
66+
* fresh LocalSubstrate per call (matches the v0 default).
67+
*/
68+
readonly substrates?: Readonly<Record<string, () => Substrate>>;
69+
/**
70+
* Default `runtime` when the request body omits it. Must be a key in
71+
* `substrates`. If unset, the first registered key wins.
72+
*/
73+
readonly defaultRuntime?: string;
74+
/**
75+
* @deprecated Pass via `substrates: { default: () => ... }` and `defaultRuntime: "default"` instead.
76+
* Kept for backward compatibility with the singular-substrate v0 shape.
5377
*/
5478
readonly substrate?: () => Substrate;
5579
/**
@@ -62,6 +86,13 @@ export interface ComputerAgentServerOptions {
6286
interface RunBody {
6387
source: IdentitySource | string;
6488
harness: string;
89+
/**
90+
* Which substrate to spawn the agent inside. Names come from the server's
91+
* `substrates` registry (e.g. "local", "bwrap", "e2b"). If omitted, the
92+
* server uses its `defaultRuntime`. Unknown values → 400 UNKNOWN_RUNTIME
93+
* with the list of available names.
94+
*/
95+
runtime?: string;
6596
message: string | Array<{ role: "user"; content: string }>;
6697
envs?: Record<string, string>;
6798
options?: Record<string, unknown>;
@@ -125,7 +156,13 @@ export class ComputerAgentServer {
125156

126157
private wire(): void {
127158
this.app.get("/health", (c) =>
128-
c.json({ ok: true, activeRuns: this.runs.size, max: this.opts.maxConcurrentRuns ?? 4 }),
159+
c.json({
160+
ok: true,
161+
activeRuns: this.runs.size,
162+
max: this.opts.maxConcurrentRuns ?? 4,
163+
runtimes: this.availableRuntimes(),
164+
defaultRuntime: this.resolveDefaultRuntime(),
165+
}),
129166
);
130167

131168
this.app.post("/run", async (c) => {
@@ -144,6 +181,15 @@ export class ComputerAgentServer {
144181
const validation = validateRunBody(body);
145182
if (validation) return c.json({ error: validation }, 400);
146183

184+
// Resolve which substrate factory to use for this request.
185+
// Precedence: body.runtime → defaultRuntime → first registered → legacy
186+
// singular `substrate` → fresh LocalSubstrate.
187+
const runtimeResult = this.resolveRuntime(body.runtime);
188+
if (!runtimeResult.ok) {
189+
return c.json({ error: runtimeResult.error }, 400);
190+
}
191+
const buildSubstrate = runtimeResult.factory;
192+
147193
const source = applyGitToken(normalizeSource(body.source), body.gitToken);
148194
const envs = {
149195
...this.opts.defaultEnvs,
@@ -158,7 +204,7 @@ export class ComputerAgentServer {
158204
const agent = new ComputerAgent({
159205
source,
160206
harness: body.harness as never,
161-
runtime: this.opts.substrate ? this.opts.substrate() : new LocalSubstrate(),
207+
runtime: buildSubstrate(),
162208
envs,
163209
...(body.options ? { options: body.options } : {}),
164210
...(body.model ? { model: body.model } : {}),
@@ -252,6 +298,61 @@ export class ComputerAgentServer {
252298
return c.json({ entries: tree });
253299
});
254300
}
301+
302+
/**
303+
* Map a per-request `runtime` name to the registered substrate factory.
304+
* Returns a discriminated result so the route handler can convert "unknown
305+
* runtime" into a clean 400 with the available list.
306+
*/
307+
private resolveRuntime(
308+
requested: string | undefined,
309+
):
310+
| { ok: true; factory: () => Substrate }
311+
| { ok: false; error: { code: string; message: string; available: string[] } } {
312+
const registry = this.opts.substrates;
313+
const fallback = this.opts.substrate;
314+
const available = this.availableRuntimes();
315+
316+
// Explicit request: must match a registered key.
317+
if (requested) {
318+
const factory = registry?.[requested];
319+
if (factory) return { ok: true, factory };
320+
// If only the legacy singular substrate is set, accept whatever the
321+
// client asked for (one-substrate deployment — the name is informational).
322+
if (!registry && fallback) return { ok: true, factory: fallback };
323+
return {
324+
ok: false,
325+
error: {
326+
code: "UNKNOWN_RUNTIME",
327+
message: `runtime '${requested}' not registered on this server`,
328+
available,
329+
},
330+
};
331+
}
332+
333+
// No explicit request: use defaultRuntime, then the first registered, then
334+
// the legacy singular factory, then a fresh LocalSubstrate.
335+
if (registry) {
336+
const defaultName = this.opts.defaultRuntime ?? Object.keys(registry)[0];
337+
const factory = defaultName ? registry[defaultName] : undefined;
338+
if (factory) return { ok: true, factory };
339+
}
340+
if (fallback) return { ok: true, factory: fallback };
341+
return { ok: true, factory: () => new LocalSubstrate() };
342+
}
343+
344+
private availableRuntimes(): string[] {
345+
if (this.opts.substrates) return Object.keys(this.opts.substrates);
346+
if (this.opts.substrate) return ["<legacy-singleton>"];
347+
return ["local"];
348+
}
349+
350+
private resolveDefaultRuntime(): string | undefined {
351+
if (this.opts.substrates) {
352+
return this.opts.defaultRuntime ?? Object.keys(this.opts.substrates)[0];
353+
}
354+
return undefined;
355+
}
255356
}
256357

257358
// ── helpers ──────────────────────────────────────────────────────────────
@@ -317,13 +418,22 @@ if (import.meta.url === `file://${process.argv[1]}`) {
317418
? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }
318419
: undefined,
319420
maxConcurrentRuns: 4,
421+
// Register every substrate the deployment supports. Clients pick which
422+
// via `"runtime": "local" | "bwrap" | ...` in the /run body.
423+
substrates: {
424+
local: () => new LocalSubstrate(),
425+
// Add others as needed:
426+
// bwrap: () => new BwrapSubstrate({ extraRoBinds: [...] }),
427+
// e2b: () => new E2BSubstrate({ apiKey: process.env.E2B_API_KEY! }),
428+
},
429+
defaultRuntime: "local",
320430
});
321431
const { host, port } = await server.listen();
322432
console.log(`ComputerAgentServer listening on http://${host}:${port}`);
323433
console.log("");
324434
console.log("Endpoints:");
325-
console.log(" GET /health");
326-
console.log(" POST /run body: {source, harness, message, envs?, options?, gitToken?, model?, debug?}");
435+
console.log(" GET /health runtimes + default + active count");
436+
console.log(" POST /run body: {source, harness, runtime?, message, envs?, options?, gitToken?, model?, sessionStore?, sessionId?, debug?}");
327437
console.log(" GET /workdir?sessionId=<id>");
328438
console.log(" GET /artifact?sessionId=<id>&path=<path>");
329439
console.log("");
@@ -333,6 +443,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
333443
console.log(" -d '{");
334444
console.log(' "source": "github.com/shreyas-lyzr/pdf-agent",');
335445
console.log(' "harness": "claude-agent-sdk",');
446+
console.log(' "runtime": "local",');
336447
console.log(' "options": { "permissionMode": "bypassPermissions", "settingSources": ["project"] },');
337448
console.log(' "message": "Write hello.pdf with one line of text"');
338449
console.log(" }'");

0 commit comments

Comments
 (0)