diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 618445ef..800e425d 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -14,8 +14,8 @@ const workspace = new Workspace({ loader: env.LOADER, root: "/workspace", access: "read-write", - defaultTimeoutMs: 10_000, - maxTimeoutMs: 30_000, + defaultTimeoutMs: 60_000, + maxTimeoutMs: 180_000, globalOutbound: null, modules: { "math-kit": `export const double = value => value * 2;`, @@ -81,16 +81,45 @@ Workspace parses the graph before loading the Worker, confines every durable pat ## Execution limits and retention -The backend admits one execution at a time by default. A concurrent start fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Set `maxConcurrentExecutions` only after measuring the Durable Object and Worker Loader limits for the deployment. +The backend admits up to twenty-four executions at a time by default. A concurrent start past that ceiling fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Adjust `maxConcurrentExecutions` after measuring the Durable Object and Worker Loader limits for the deployment. Each execution also bounds log events, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxLogEvents`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. -Completed execution records remain available for replay for five minutes by default. The backend also keeps at most 100 completed records. Configure these bounds with `retentionMs` and `maxRetainedExecutions`. Completed records leave the in-memory active set immediately; replay reads them from SQLite. +Completed execution records remain available for replay for sixty minutes by default. The backend also keeps at most 100 completed records. Configure these bounds with `retentionMs` and `maxRetainedExecutions`. Completed records leave the in-memory active set immediately; replay reads them from SQLite. Cancellation stops new host capability calls, disposes the Dynamic Worker, and waits for host calls that were already accepted. Exit 130 is published only after those calls settle. Normal completion uses the same drain rule, so an unawaited capability call cannot mutate the workspace after exit 0. Host calls have a caller-visible deadline, controlled by `maxHostCallMs` and defaulting to `maxTimeoutMs`. Missing the deadline fails the capability call and marks the execution failed, even if caller code catches that error. Execution still waits for the accepted host operation itself before publishing a terminal event because many host APIs cannot roll back an external side effect after dispatch. Trusted modules receive an optional `{ signal, deadline }` context and must stop promptly when the signal aborts. A trusted module that ignores cancellation and never settles will keep execution in its finalizing state. `compatibilityDate` and `compatibilityFlags` control the Dynamic Worker runtime and default to the package-tested settings. +## Environment, standard input, and the `process` shim + +Each execution installs a small `node:process` shim so ordinary module code can read its environment and standard streams. The shim exposes only what the caller supplies for that execution; the host environment is never visible. + +`process.env` is a snapshot of the `env` record passed on the exec options. Values the caller does not pass are absent, and the Durable Object's own environment is never merged in, so a module cannot read host bindings or secrets through `process.env`. + +`process.stdin` is a non-interactive async-iterable over the caller-supplied `stdin` bytes. The caller passes `stdin` as a `Uint8Array` or string on the exec options; `for await` yields the bytes once and then ends, and there is no blocking read for further input because an evaluate-once execution has no session to wait on. `isTTY` is `false`. The supplied input is bounded by `maxStdinBytes`; exceeding it fails the run with a clear error. + +`process.stdout` and `process.stderr` are writable streams whose writes are captured as standard output and standard error. `console.log` and `console.info` route to standard output, `console.warn` and `console.error` route to standard error, and the captured output is bounded. `process.argv`, `process.cwd()`, and `process.platform` return inert values: `cwd()` reflects the execution's working directory, while `argv` and `platform` carry fixed placeholders rather than describing the host process. + +```ts +const handle = await workspace.runtime.exec( + ` + export default async function main() { + let piped = ""; + for await (const chunk of process.stdin) piped += new TextDecoder().decode(chunk); + console.log("received", piped.length, "bytes"); + return { who: process.env.WHO, piped }; + } + `, + { + backend: "worker-javascript", + env: { WHO: "demo" }, + stdin: "hello", + encoding: "utf8", + }, +); +``` + ## Configured modules Bare imports are installed at backend construction, not passed on individual executions: @@ -156,7 +185,7 @@ Each execution receives a fresh Dynamic Worker with: - a host wall-clock deadline; - `globalOutbound: null` by default; - finite, acyclic JSON-compatible input and structured result validation; -- configurable source/module graph, input, result, captured-log, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxLogBytes`, and `maxCapabilityBytes`); +- configurable source/module graph, input, result, stdin, captured-log, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxStdinBytes`, `maxLogBytes`, and `maxCapabilityBytes`); - explicit entrypoint and Worker disposal; - host-owned cancellation; - retained events and result rows in the Workspace database. diff --git a/examples/worker-javascript/README.md b/examples/worker-javascript/README.md index 05cd2d1a..98221f66 100644 --- a/examples/worker-javascript/README.md +++ b/examples/worker-javascript/README.md @@ -86,7 +86,7 @@ npm run seed:r2 --workspace @example/computer-worker-javascript PUT /c//file/workspace/ raw body → writeFile at /workspace/ GET /c//file/workspace/ octet-stream of /workspace/ (any path outside /workspace returns 400) -POST /c//exec { source, input?, cwd? } +POST /c//exec { source, input?, cwd?, env?, stdin? } cwd defaults to /workspace → JSON { status, exitCode, stdout, stderr, value } ``` @@ -120,8 +120,18 @@ curl -X POST http://127.0.0.1:8787/c/demo/exec \ curl -X POST http://127.0.0.1:8787/c/demo/exec \ -H 'content-type: application/json' \ -d '{"source":"export default (input) => input.n * 2;","input":{"n":21}}' + +curl -X POST http://127.0.0.1:8787/c/demo/exec \ + -H 'content-type: application/json' \ + -d '{"source":"export default async () => { let s = \"\"; for await (const c of process.stdin) s += new TextDecoder().decode(c); return process.env.WHO + \":\" + s; };","env":{"WHO":"demo"},"stdin":"piped"}' ``` +`env` populates `process.env` (only the values you pass; the host +environment is never exposed), and `stdin` is readable through +`process.stdin`. `console.log` / `console.error` and +`process.stdout` / `process.stderr` writes come back as the result's +`stdout` and `stderr`. + ## Layout ``` diff --git a/examples/worker-javascript/src/index.ts b/examples/worker-javascript/src/index.ts index 4f0fc3ea..3aa5758f 100644 --- a/examples/worker-javascript/src/index.ts +++ b/examples/worker-javascript/src/index.ts @@ -25,6 +25,8 @@ interface ExecRequest { source?: string; input?: WorkspaceRuntimeValue; cwd?: string; + env?: Record; + stdin?: string; } const MOUNT_ROOT = "/workspace"; @@ -130,6 +132,8 @@ async function handleExec(request: Request, env: Env, name: string): Promise { await expect(execution).rejects.toMatchObject({ code: "ECLOSED" }); }); + it("rejects stdin larger than the configured ceiling", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load }, maxStdinBytes: 8 })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec("export default 1", { stdin: "x".repeat(64) }), + ).rejects.toThrow(/stdin exceeds 8 bytes/); + expect(load).not.toHaveBeenCalled(); + }); + + it("rejects env larger than the configured ceiling", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load }, maxEnvBytes: 8 })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec("export default 1", { env: { KEY: "x".repeat(64) } }), + ).rejects.toThrow(/env exceeds 8 bytes/); + expect(load).not.toHaveBeenCalled(); + }); + + it("rejects non-string env values", async () => { + const load = vi.fn(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load } })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await expect( + workspace.runtime.exec("export default 1", { + env: { KEY: 42 as unknown as string }, + }), + ).rejects.toThrow(/env value for "KEY" must be a string/); + expect(load).not.toHaveBeenCalled(); + }); + it("checks limits against the complete loader map including the runtime runner", async () => { const load = vi.fn(); const workspace = new Workspace({ @@ -343,6 +384,7 @@ describe("WorkerJavaScriptBackend", () => { waitUntil, backends: [ new WorkerJavaScriptBackend({ + maxConcurrentExecutions: 1, loader: { load() { return { diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index daba8018..f0bd31a7 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -29,6 +29,8 @@ export interface WorkerJavaScriptBackendOptions { maxTimeoutMs?: number; maxSourceBytes?: number; maxInputBytes?: number; + maxStdinBytes?: number; + maxEnvBytes?: number; maxResultBytes?: number; maxLogBytes?: number; maxLogEvents?: number; @@ -67,6 +69,8 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "maxTimeoutMs" | "maxSourceBytes" | "maxInputBytes" + | "maxStdinBytes" + | "maxEnvBytes" | "maxResultBytes" | "maxLogBytes" | "maxLogEvents" @@ -87,11 +91,23 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< > & WorkerJavaScriptBackendOptions; +interface WorkspaceExecutionContext { + env: Record; + cwd: string; + stdin: Uint8Array; +} + +interface RuntimeLogEntry { + stream: "stdout" | "stderr"; + text: string; +} + interface JavaScriptEntrypoint { evaluate( input: WorkspaceRuntimeValue, host: WorkspaceRuntimeBridge, - ): Promise<{ result?: unknown; logs?: string[]; error?: string }>; + context: WorkspaceExecutionContext, + ): Promise<{ result?: unknown; logs?: RuntimeLogEntry[]; error?: string }>; [Symbol.dispose]?: () => void; } @@ -128,19 +144,21 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { constructor(options: WorkerJavaScriptBackendOptions) { this.id = options.id ?? "worker-javascript"; - const maxTimeoutMs = options.maxTimeoutMs ?? 30_000; - const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(10_000, maxTimeoutMs); + const maxTimeoutMs = options.maxTimeoutMs ?? 180_000; + const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(60_000, maxTimeoutMs); assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); - assertPositiveFinite(options.maxSourceBytes ?? 256 * 1024, "maxSourceBytes"); - assertPositiveFinite(options.maxInputBytes ?? 256 * 1024, "maxInputBytes"); + assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); + assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); + assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); + assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes"); assertPositiveFinite(options.maxLogBytes ?? 256 * 1024, "maxLogBytes"); assertPositiveInteger(options.maxLogEvents ?? 1024, "maxLogEvents"); assertPositiveFinite(options.maxCapabilityBytes ?? 1024 * 1024, "maxCapabilityBytes"); assertPositiveFinite(options.maxHostCallMs ?? maxTimeoutMs, "maxHostCallMs"); assertPositiveInteger( - options.maxConcurrentCapabilityCalls ?? 16, + options.maxConcurrentCapabilityCalls ?? 32, "maxConcurrentCapabilityCalls", ); assertPositiveInteger(options.maxCapabilityCalls ?? 256, "maxCapabilityCalls"); @@ -153,9 +171,9 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { "maxCapabilityResponseBytes", ); assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); - assertPositiveInteger(options.maxConcurrentExecutions ?? 1, "maxConcurrentExecutions"); + assertPositiveInteger(options.maxConcurrentExecutions ?? 24, "maxConcurrentExecutions"); assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); - assertPositiveFinite(options.retentionMs ?? 5 * 60_000, "retentionMs"); + assertPositiveFinite(options.retentionMs ?? 60 * 60_000, "retentionMs"); assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) { throw new Error("WorkerJavaScriptBackend maxCapabilityBytes must be at least 256 bytes."); @@ -173,21 +191,23 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, - maxSourceBytes: options.maxSourceBytes ?? 256 * 1024, - maxInputBytes: options.maxInputBytes ?? 256 * 1024, + maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, + maxInputBytes: options.maxInputBytes ?? 1024 * 1024, + maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, + maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, maxResultBytes: options.maxResultBytes ?? 1024 * 1024, maxLogBytes: options.maxLogBytes ?? 256 * 1024, maxLogEvents: options.maxLogEvents ?? 1024, maxCapabilityBytes: options.maxCapabilityBytes ?? 1024 * 1024, maxHostCallMs: options.maxHostCallMs ?? maxTimeoutMs, - maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 16, + maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 32, maxCapabilityCalls: options.maxCapabilityCalls ?? 256, maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, - maxConcurrentExecutions: options.maxConcurrentExecutions ?? 1, + maxConcurrentExecutions: options.maxConcurrentExecutions ?? 24, maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, - retentionMs: options.retentionMs ?? 5 * 60_000, + retentionMs: options.retentionMs ?? 60 * 60_000, maxRetainedExecutions: options.maxRetainedExecutions ?? 100, compatibilityDate, compatibilityFlags: options.compatibilityFlags ?? ["nodejs_compat"], @@ -298,6 +318,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { const inputValue = input.input ?? null; assertRuntimeValue(inputValue); assertEncodedSize(inputValue, this.#options.maxInputBytes, "input"); + const stdinBytes = normalizeStdin(input.stdin); + if (stdinBytes.byteLength > this.#options.maxStdinBytes) { + throw new Error(`Workspace runtime stdin exceeds ${this.#options.maxStdinBytes} bytes.`); + } + assertEnv(input.env, this.#options.maxEnvBytes); if (new TextEncoder().encode(input.source).byteLength > this.#options.maxSourceBytes) { throw new Error(`Workspace runtime source exceeds ${this.#options.maxSourceBytes} bytes.`); } @@ -378,6 +403,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { modules: graph.modules, entryName: graph.entryName, input: inputValue, + context: { + env: input.env ?? {}, + cwd: input.cwd ?? this.#options.root, + stdin: stdinBytes, + }, bridge, timeoutMs, globalOutbound: this.#options.globalOutbound ?? null, @@ -546,7 +576,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { #complete( record: ExecutionRecord, - outcome: { result?: unknown; logs?: string[]; error?: string }, + outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, ): Promise { if (record.finalization) return record.finalization; if (record.status !== "running") return Promise.resolve(); @@ -557,7 +587,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { async #completeOnce( record: ExecutionRecord, - outcome: { result?: unknown; logs?: string[]; error?: string }, + outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, ) { try { await record.bridge?.cancelAndDrain(); @@ -577,12 +607,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } try { for (const log of outcome.logs ?? []) { - const stderr = log.startsWith("[warn] ") || log.startsWith("[error] "); this.#append(record, { id: record.id, seq: record.events.length + 1, - name: stderr ? "stderr" : "stdout", - value: new TextEncoder().encode(`${log}\n`), + name: log.stream, + value: new TextEncoder().encode(log.text), }); } if (outcome.error !== undefined) { @@ -851,6 +880,7 @@ function startJavaScriptExecution(options: { modules: Record; entryName: string; input: WorkspaceRuntimeValue; + context: WorkspaceExecutionContext; bridge: WorkspaceRuntimeBridge; timeoutMs: number; globalOutbound: Fetcher | null; @@ -860,7 +890,11 @@ function startJavaScriptExecution(options: { maxLogEvents: number; maxResultBytes: number; maxSourceBytes: number; - onComplete(outcome: { result?: unknown; logs?: string[]; error?: string }): void | Promise; + onComplete(outcome: { + result?: unknown; + logs?: RuntimeLogEntry[]; + error?: string; + }): void | Promise; }): ActiveControl { const modules = { ...options.modules, @@ -903,7 +937,9 @@ function startJavaScriptExecution(options: { cancelExecution = reject; }); const execution = Promise.race([ - Promise.resolve().then(() => entrypoint.evaluate(options.input, options.bridge)), + Promise.resolve().then(() => + entrypoint.evaluate(options.input, options.bridge, options.context), + ), new Promise((_, reject) => { timer = setTimeout( () => reject(new Error("JavaScript execution timed out")), @@ -958,48 +994,116 @@ function runtimeWorkerModule( import { install } from "workspace-capabilities.js"; export default class extends WorkerEntrypoint { - async evaluate(input, host) { + async evaluate(input, host, context) { + const env = (context && context.env) || {}; + const cwd = (context && context.cwd) || "/workspace"; + const stdinBytes = (context && context.stdin) || new Uint8Array(0); + const stdin = { + isTTY: false, + [Symbol.asyncIterator]() { + let done = false; + return { + next() { + if (done) return Promise.resolve({ done: true, value: undefined }); + done = true; + if (stdinBytes.byteLength === 0) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: stdinBytes }); + }, + }; + }, + }; + const nextProcess = { + env, + argv: ["workspace", ${JSON.stringify(entryName)}], + cwd: () => cwd, + platform: "linux", + stdin, + }; + try { + globalThis.process = nextProcess; + } catch {} + if (globalThis.process !== nextProcess) { + try { + Object.defineProperty(globalThis, "process", { + value: nextProcess, + configurable: true, + writable: true, + }); + } catch {} + } + if (globalThis.process !== nextProcess && globalThis.process) { + try { + globalThis.process.env = env; + } catch {} + try { + globalThis.process.stdin = stdin; + } catch {} + } const logs = []; const encoder = new TextEncoder(); let logBytes = 0; + let logEvents = 0; let logsTruncated = false; - const capture = (prefix, args) => { + const record = (stream, text) => { if (logsTruncated) return; - if (logs.length >= ${maxLogEvents - 1}) { - logs.push("...[logs truncated]"); + if (logEvents >= ${maxLogEvents - 1}) { + logs.push({ stream, text: "...[logs truncated]" }); logsTruncated = true; return; } - const line = prefix + args.map(String).join(" "); - const bytes = encoder.encode(line); + const bytes = encoder.encode(text); const remaining = ${maxLogBytes} - logBytes; - if (bytes.byteLength + 1 <= remaining) { - logs.push(line); - logBytes += bytes.byteLength + 1; + if (bytes.byteLength <= remaining) { + logs.push({ stream, text }); + logBytes += bytes.byteLength; + logEvents += 1; return; } const marker = encoder.encode("...[logs truncated]"); - const available = remaining - marker.byteLength - 1; + const available = remaining - marker.byteLength; if (available >= 0) { - let prefix = bytes.slice(0, available); + let head = bytes.slice(0, available); let partial = ""; - while (prefix.byteLength > 0) { + while (head.byteLength > 0) { try { - partial = new TextDecoder("utf-8", { fatal: true }).decode(prefix); + partial = new TextDecoder("utf-8", { fatal: true }).decode(head); break; } catch { - prefix = prefix.slice(0, -1); + head = head.slice(0, -1); } } - logs.push(partial + "...[logs truncated]"); - logBytes += prefix.byteLength + marker.byteLength + 1; + logs.push({ stream, text: partial + "...[logs truncated]" }); + logBytes += head.byteLength + marker.byteLength; } logsTruncated = true; }; - console.log = (...args) => capture("", args); - console.info = (...args) => capture("", args); - console.warn = (...args) => capture("[warn] ", args); - console.error = (...args) => capture("[error] ", args); + const consoleLine = (stream, args) => record(stream, args.map(String).join(" ") + "\\n"); + console.log = (...args) => consoleLine("stdout", args); + console.info = (...args) => consoleLine("stdout", args); + console.warn = (...args) => consoleLine("stderr", args); + console.error = (...args) => consoleLine("stderr", args); + nextProcess.stdout = { + write: (chunk) => { + record("stdout", typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }, + }; + nextProcess.stderr = { + write: (chunk) => { + record("stderr", typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }, + }; + if (globalThis.process !== nextProcess && globalThis.process) { + try { + globalThis.process.stdout = nextProcess.stdout; + } catch {} + try { + globalThis.process.stderr = nextProcess.stderr; + } catch {} + } install(host); try { const module = await import(${JSON.stringify(entryName)}); @@ -1068,6 +1172,31 @@ function assertEncodedSize(value: WorkspaceRuntimeValue, maxBytes: number, name: } } +function normalizeStdin(stdin: Uint8Array | string | undefined): Uint8Array { + if (stdin === undefined) return new Uint8Array(0); + if (typeof stdin === "string") return new TextEncoder().encode(stdin); + if (stdin instanceof Uint8Array) return stdin; + throw new Error("Workspace runtime stdin must be a string or Uint8Array."); +} + +function assertEnv(env: Record | undefined, maxBytes: number): void { + if (env === undefined) return; + if (typeof env !== "object" || env === null || Array.isArray(env)) { + throw new Error("Workspace runtime env must be a string-to-string record."); + } + let bytes = 0; + const encoder = new TextEncoder(); + for (const [key, value] of Object.entries(env)) { + if (typeof value !== "string") { + throw new Error(`Workspace runtime env value for ${JSON.stringify(key)} must be a string.`); + } + bytes += encoder.encode(key).byteLength + encoder.encode(value).byteLength; + } + if (bytes > maxBytes) { + throw new Error(`Workspace runtime env exceeds ${maxBytes} bytes.`); + } +} + function assertPositiveFinite(value: number, name: string) { if (!Number.isFinite(value) || value <= 0) { throw new Error(`WorkerJavaScriptBackend ${name} must be a positive finite number.`); diff --git a/packages/computer/src/client.ts b/packages/computer/src/client.ts index 61e5d10c..6e3bf4b2 100644 --- a/packages/computer/src/client.ts +++ b/packages/computer/src/client.ts @@ -225,6 +225,8 @@ export interface RuntimeExecOptions { id?: string; timeoutMs?: number; input?: WorkspaceRuntimeValue; + env?: Record; + stdin?: Uint8Array | string; } export interface RuntimeGetOptions { diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 5ea9c5a1..2fa320f3 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -75,6 +75,8 @@ export class WorkspaceRuntime { source, cwd: options.cwd, input: options.input, + env: options.env, + stdin: options.stdin, timeoutMs: options.timeoutMs, }); return wrapModuleHandle(runtime, backend, envelope.id, envelope.events, options.encoding); diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index a7aaa782..3fe7c341 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -108,6 +108,8 @@ export interface WorkspaceRuntimeExecOptions cwd?: string; encoding?: E; input?: WorkspaceRuntimeValue; + env?: Record; + stdin?: Uint8Array | string; timeoutMs?: number; } @@ -140,6 +142,8 @@ export interface ModuleExecutionInput { source: string; cwd?: string; input?: WorkspaceRuntimeValue; + env?: Record; + stdin?: Uint8Array | string; timeoutMs?: number; } diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 0d1fab2b..6df9cc11 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -83,6 +83,8 @@ export class HostDO extends DurableObject { cwd?: string; value?: WorkspaceRuntimeValue; id?: string; + env?: Record; + stdin?: string; }) { await this.#workspace.fs.mkdir("/workspace", { recursive: true }); const handle = await this.#workspace.runtime.exec(input.source, { @@ -90,6 +92,8 @@ export class HostDO extends DurableObject { cwd: input.cwd, input: input.value, id: input.id, + env: input.env, + stdin: input.stdin, encoding: "utf8", }); return { id: handle.id, result: await handle.result() }; @@ -207,6 +211,8 @@ export default class extends WorkerEntrypoint { cwd?: string; value?: WorkspaceRuntimeValue; id?: string; + env?: Record; + stdin?: string; }, ), ); diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 03323c01..48c02084 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -119,6 +119,93 @@ describe("WorkspaceRuntime", () => { }); }); + it("exposes caller-supplied env through process.env and hides host env", async () => { + const response = await runtime({ + source: ` + export default () => ({ + greeting: process.env.GREETING ?? null, + hasHostSecret: "HOST_SECRET" in process.env, + keys: Object.keys(process.env).sort(), + }); + `, + env: { GREETING: "hello" }, + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { + greeting: "hello", + hasHostSecret: false, + keys: ["GREETING"], + }, + }, + }); + }); + + it("reflects the exec cwd and inert argv/platform on process", async () => { + const response = await runtime({ + source: ` + export default () => ({ + cwd: process.cwd(), + argvLength: process.argv.length, + platform: process.platform, + }); + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { cwd: "/workspace", argvLength: 2, platform: "linux" }, + }, + }); + }); + + it("exposes caller-supplied stdin as an async-iterable process.stdin", async () => { + const response = await runtime({ + source: ` + export default async () => { + const decoder = new TextDecoder(); + let text = ""; + for await (const chunk of process.stdin) text += decoder.decode(chunk); + return { text, isTTY: process.stdin.isTTY }; + }; + `, + stdin: "hello stdin", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text), text).toMatchObject({ + result: { + status: "completed", + value: { text: "hello stdin", isTTY: false }, + }, + }); + }); + + it("routes console and process.stdout/stderr writes to the right streams", async () => { + const response = await runtime({ + source: ` + export default () => { + console.log("log-line"); + console.error("error-line"); + process.stderr.write("raw-err"); + return true; + }; + `, + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const payload = JSON.parse(text); + expect(payload.result.status).toBe("completed"); + expect(payload.result.stdout).toBe("log-line\n"); + expect(payload.result.stderr).toBe("error-line\nraw-err"); + }); + it("bounds persisted console output including truncation markers and newlines", async () => { const response = await runtime({ source: `export default () => { console.log("🙂".repeat(256)); return true; };`, @@ -156,7 +243,7 @@ describe("WorkspaceRuntime", () => { expect(response.status, text).toBe(200); const payload = JSON.parse(text); expect(payload.result.stdout.split("\n").filter(Boolean)).toEqual(["...[logs truncated]"]); - expect(payload.result.stdout.split("\n").length - 1).toBe(4); + expect(payload.result.stdout.split("\n").length - 1).toBe(3); }); it("bounds concurrent host capability calls", async () => {