From 897bf661b8aaacdef25b7ee5d91e00d742bb0804 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:01 +0000 Subject: [PATCH 1/8] workspace: add sh tagged template for shell escaping Building a shell command by interpolating values into a string is a shell-injection risk: a path or branch name can break out of its argument and run arbitrary commands. Every consumer that built commands by hand grew its own quoting helper to avoid that, which is easy to forget and noisy at the call site. Add an sh tagged template that escapes every interpolated value. Strings and numbers are quoted, arrays are quoted element-by-element and joined with spaces, and a raw-marked value is spliced in verbatim for the rare case that genuinely means shell syntax. The static parts come from strings.raw, so a backslash written in the template reaches the shell as written. shellQuote is exported for single-argument quoting outside a template. WorkspaceShell.exec rejects a tagged-template call with a clear error. Escaping has to run on the caller's side, because a TemplateStringsArray's raw property does not survive structured clone over RPC; the guard keeps the unescaped path from running silently. --- packages/workspace/src/sh.test.ts | 96 ++++++++++++++++++++++++++++ packages/workspace/src/sh.ts | 102 ++++++++++++++++++++++++++++++ packages/workspace/src/shell.ts | 2 + 3 files changed, 200 insertions(+) create mode 100644 packages/workspace/src/sh.test.ts create mode 100644 packages/workspace/src/sh.ts diff --git a/packages/workspace/src/sh.test.ts b/packages/workspace/src/sh.test.ts new file mode 100644 index 00000000..1a6bd827 --- /dev/null +++ b/packages/workspace/src/sh.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { assertNotTemplate, sh, shellQuote } from "./sh.js"; + +describe("shellQuote", () => { + it("leaves safe values unquoted for readability", () => { + expect(shellQuote("main")).toBe("main"); + expect(shellQuote("/workspace/my-repo")).toBe("/workspace/my-repo"); + expect(shellQuote("a_b-c+d=e:f,g.h/i@j%k")).toBe("a_b-c+d=e:f,g.h/i@j%k"); + }); + + it("quotes values with shell metacharacters", () => { + expect(shellQuote("hello world")).toBe("'hello world'"); + expect(shellQuote("a;rm -rf /")).toBe("'a;rm -rf /'"); + expect(shellQuote("$(whoami)")).toBe("'$(whoami)'"); + }); + + it("escapes embedded single quotes", () => { + expect(shellQuote("it's")).toBe(`'it'\\''s'`); + }); + + it("quotes the empty string", () => { + expect(shellQuote("")).toBe("''"); + }); +}); + +describe("assertNotTemplate", () => { + it("accepts a plain string", () => { + expect(() => assertNotTemplate("cat file")).not.toThrow(); + }); + + it("rejects a tagged-template call and points at sh", () => { + const tag = (strings: TemplateStringsArray, ...values: unknown[]) => { + void values; + return strings; + }; + const asTemplate = tag`cat ${"file"}`; + expect(() => assertNotTemplate(asTemplate)).toThrow(/getWorkspace/); + }); + + it("rejects the template array even after it loses .raw over the wire", () => { + // Workers RPC structured-clones the TemplateStringsArray, which + // drops `.raw` but keeps the indexed entries. The guard keys off + // the array shape, not `.raw`, so it still fires on the far side. + const wireShape = ["cat ", ""]; + expect("raw" in wireShape).toBe(false); + expect(() => assertNotTemplate(wireShape)).toThrow(/getWorkspace/); + }); +}); + +describe("sh", () => { + it("emits the static parts verbatim and escapes interpolations", () => { + expect(sh`cat ${"my file.txt"}`).toBe("cat 'my file.txt'"); + }); + + it("leaves simple interpolated values unquoted", () => { + const file = "/workspace/notes.md"; + expect(sh`cat ${file}`).toBe("cat /workspace/notes.md"); + }); + + it("neutralizes injection attempts in interpolated values", () => { + const evil = "x; rm -rf /"; + expect(sh`echo ${evil}`).toBe("echo 'x; rm -rf /'"); + }); + + it("quotes numbers as strings", () => { + expect(sh`sleep ${5}`).toBe("sleep 5"); + }); + + it("quotes each element of an array and joins with spaces", () => { + const files = ["a.txt", "b c.txt"]; + expect(sh`rm ${files}`).toBe("rm a.txt 'b c.txt'"); + }); + + it("handles multiple interpolations", () => { + const src = "from dir"; + const dst = "to dir"; + expect(sh`cp ${src} ${dst}`).toBe("cp 'from dir' 'to dir'"); + }); + + it("splices { raw } values verbatim", () => { + const dir = "my dir"; + expect(sh`ls ${dir} ${{ raw: "| wc -l" }}`).toBe("ls 'my dir' | wc -l"); + }); + + it("preserves backslashes in the static template via strings.raw", () => { + const file = "notes.txt"; + // A `\n` in the template stays a literal backslash-n for the + // shell instead of being cooked into a newline. + expect(sh`grep \n ${file}`).toBe("grep \\n notes.txt"); + }); + + it("works with no interpolations", () => { + expect(sh`git status`).toBe("git status"); + }); +}); diff --git a/packages/workspace/src/sh.ts b/packages/workspace/src/sh.ts new file mode 100644 index 00000000..7af57fa6 --- /dev/null +++ b/packages/workspace/src/sh.ts @@ -0,0 +1,102 @@ +// Shell command templating with automatic escaping. +// +// `Workspace.shell.exec` takes a single command string that the +// container runs through `/bin/sh -c`. Building that string by hand +// is a shell-injection footgun: any interpolated path, branch name, +// or user-supplied value can break out of its argument and run +// arbitrary commands. Every example in this repo grew its own +// `shellQuote` helper to dodge that, which is duplicated, easy to +// forget, and noisy at the call site. +// +// `sh` is a tagged template that escapes interpolated values for +// you, so +// +// sh`cat ${file}` +// +// quotes `file` no matter what it contains. The literal parts of the +// template are emitted verbatim — they're the trusted command — and +// only the `${...}` substitutions are escaped. The static parts come +// from `strings.raw`, so a backslash you write in the template (for +// a `sed` script, say) reaches the shell as you wrote it rather than +// as a processed escape sequence. +// +// Interpolation rules: +// - strings and numbers are quoted with `shellQuote`; +// - arrays are quoted element-by-element and joined with spaces, so +// `sh`rm ${files}`` expands to a safe argument list; +// - a `{ raw: "..." }` value is spliced in verbatim, for the rare +// case where you genuinely mean shell syntax (a pipe, a redirect, +// a pre-quoted sub-command). This mirrors Bun's shell API. + +// A value `sh` splices in without escaping. The escape hatch from +// auto-escaping: use it only for values you control and know are +// shell-safe. +export interface RawShellValue { + raw: string; +} + +// A value `sh` knows how to interpolate. Strings and numbers are +// quoted; arrays are quoted per-element; `{ raw: "..." }` passes +// through untouched. +export type ShellValue = string | number | RawShellValue | ReadonlyArray; + +function isRaw(value: ShellValue): value is RawShellValue { + return typeof value === "object" && value !== null && !Array.isArray(value) && "raw" in value; +} + +// Quote a single argument for `/bin/sh -c`. Values made up only of +// characters that the shell never treats specially are returned as-is +// for readability; everything else is wrapped in single quotes with +// embedded single quotes escaped as `'\''`. +export function shellQuote(arg: string): string { + if (arg.length > 0 && /^[A-Za-z0-9_\-+=:,./@%]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +function interpolate(value: ShellValue): string { + if (isRaw(value)) return value.raw; + if (Array.isArray(value)) { + return (value as ReadonlyArray) + .map((item) => shellQuote(String(item))) + .join(" "); + } + return shellQuote(String(value)); +} + +// Guard against `exec` being called as a tagged template. `exec` +// takes a plain command string, and over Workers RPC that string is +// all that crosses the wire — a `TemplateStringsArray`'s `.raw` +// property is dropped by structured clone, so escaping can't happen +// on the far side. Escaping has to run caller-side through `sh`, +// which collapses to a string before the call. +// +// A command is never legitimately an array, so reject any array. +// That catches both the local `exec`cat ${x}`` mistake (the array +// still carries `.raw`) and the same call made through a worker-side +// stub, where structured clone has already stripped `.raw` but the +// indexed array shape survives. Either way the unsafe path fails +// loudly and points at the fix. +export function assertNotTemplate(command: unknown): asserts command is string { + if (Array.isArray(command)) { + throw new TypeError( + "The remote stub's exec() does not take a tagged template. Reach the " + + "Workspace through getWorkspace(stub) and call exec as a template there, " + + "or wrap the command in the sh tag so interpolated values are escaped " + + "before the command crosses the RPC boundary.", + ); + } +} + +// Tagged template that builds a shell command string with every +// interpolated value escaped. The static template parts come from +// `strings.raw` — they're trusted and emitted verbatim, backslashes +// and all — and only `${...}` substitutions are escaped. See the file +// header for the interpolation rules. +export function sh(strings: TemplateStringsArray, ...values: ShellValue[]): string { + const parts = strings.raw; + let out = parts[0]; + for (let i = 0; i < values.length; i++) { + out += interpolate(values[i]) + parts[i + 1]; + } + return out; +} diff --git a/packages/workspace/src/shell.ts b/packages/workspace/src/shell.ts index 14f5083b..3ae543e9 100644 --- a/packages/workspace/src/shell.ts +++ b/packages/workspace/src/shell.ts @@ -33,6 +33,7 @@ import type { ApplyResult, SkippedEntry } from "@cloudflare/dofs"; import type { ExecEvent, ShellRPC } from "@cloudflare/workspace-rpc"; import { noopObserver, type WorkspaceObserver, withSpan } from "./observe.js"; +import { assertNotTemplate } from "./sh.js"; export type ExecEncoding = "utf8" | undefined; @@ -148,6 +149,7 @@ export class WorkspaceShell { command: string, options: ExecOptions = {}, ): Promise> { + assertNotTemplate(command); // Pre-exec push: ship anything the host wrote since the last // push so the spawned command sees it. Failures non-fatal per // docs/05 — the command still runs; pushed reports 0. From f272ad5a8fc47e285380c4787c55a9ad82992a09 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:17 +0000 Subject: [PATCH 2/8] workspace: add exec event wire codec Workers RPC carries byte streams with flow control but not an arbitrary object stream. To project a streaming exec across the durable-object boundary, the event stream has to be framed as bytes on one side and parsed back on the other. Add encodeExecEvents and decodeExecEvents, which frame a stream of exec events as newline-delimited JSON and inflate it back. Text chunks ride as JSON strings; binary chunks are base64-encoded so the frame stays valid JSON regardless of the bytes. The decoder buffers partial lines so a chunk boundary can fall anywhere. The codec is a pure unit with no RPC dependency, tested over a round trip including multi-byte text, non-utf8 bytes, and split chunk boundaries. --- packages/workspace/src/exec-wire.test.ts | 120 +++++++++++++++++++++ packages/workspace/src/exec-wire.ts | 132 +++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 packages/workspace/src/exec-wire.test.ts create mode 100644 packages/workspace/src/exec-wire.ts diff --git a/packages/workspace/src/exec-wire.test.ts b/packages/workspace/src/exec-wire.test.ts new file mode 100644 index 00000000..569d71f5 --- /dev/null +++ b/packages/workspace/src/exec-wire.test.ts @@ -0,0 +1,120 @@ +// Unit tests for the exec event wire codec. +// +// The codec frames a stream of WorkspaceExecEvents as JSONL bytes so +// the event stream can cross Workers RPC (which carries byte streams +// but not arbitrary object streams), then inflates it back on the far +// side. These tests run the pure encode/decode round trip in-process. + +import { describe, expect, it } from "vitest"; + +import { decodeExecEvents, encodeExecEvents } from "./exec-wire.js"; +import type { WorkspaceExecEvent } from "./shell.js"; + +function streamOf(items: T[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const item of items) controller.enqueue(item); + controller.close(); + }, + }); +} + +async function collect(stream: ReadableStream): Promise { + const out: T[] = []; + const reader = stream.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + out.push(value); + } + } finally { + reader.releaseLock(); + } + return out; +} + +async function roundTrip( + events: WorkspaceExecEvent[], +): Promise[]> { + const bytes = encodeExecEvents(streamOf(events)); + return collect(decodeExecEvents(bytes)) as Promise[]>; +} + +describe("exec wire codec — utf8 events", () => { + it("round-trips stdout/stderr/exit events", async () => { + const events: WorkspaceExecEvent<"utf8">[] = [ + { id: "x", seq: 0, name: "stdout", value: "hello\n" }, + { id: "x", seq: 1, name: "stderr", value: "oops\n" }, + { id: "x", seq: 2, name: "exit", value: 0 }, + ]; + expect(await roundTrip(events)).toEqual(events); + }); + + it("preserves multi-byte text", async () => { + const events: WorkspaceExecEvent<"utf8">[] = [ + { id: "x", seq: 0, name: "stdout", value: "café — 日本語 🎉" }, + { id: "x", seq: 1, name: "exit", value: 0 }, + ]; + expect(await roundTrip(events)).toEqual(events); + }); + + it("preserves text containing newlines and JSON metacharacters", async () => { + const events: WorkspaceExecEvent<"utf8">[] = [ + { id: "x", seq: 0, name: "stdout", value: 'line1\nline2\t{"k":"v"}\n' }, + { id: "x", seq: 1, name: "exit", value: 0 }, + ]; + expect(await roundTrip(events)).toEqual(events); + }); +}); + +describe("exec wire codec — binary events", () => { + it("round-trips Uint8Array stdout, including non-utf8 bytes", async () => { + const value = new Uint8Array([0x00, 0xff, 0xfe, 0x10, 0x80, 0x7f]); + const events: WorkspaceExecEvent[] = [ + { id: "x", seq: 0, name: "stdout", value }, + { id: "x", seq: 1, name: "exit", value: 0 }, + ]; + const out = await roundTrip(events); + expect(out[0].name).toBe("stdout"); + expect(out[0].value).toBeInstanceOf(Uint8Array); + expect(Array.from(out[0].value as Uint8Array)).toEqual(Array.from(value)); + expect(out[1]).toEqual({ id: "x", seq: 1, name: "exit", value: 0 }); + }); + + it("round-trips an empty byte chunk", async () => { + const events: WorkspaceExecEvent[] = [ + { id: "x", seq: 0, name: "stdout", value: new Uint8Array(0) }, + { id: "x", seq: 1, name: "exit", value: 0 }, + ]; + const out = await roundTrip(events); + expect(out[0].value).toBeInstanceOf(Uint8Array); + expect((out[0].value as Uint8Array).byteLength).toBe(0); + }); +}); + +describe("exec wire codec — framing", () => { + it("tolerates chunk boundaries that split a JSON line", async () => { + const events: WorkspaceExecEvent<"utf8">[] = [ + { id: "x", seq: 0, name: "stdout", value: "abc" }, + { id: "x", seq: 1, name: "exit", value: 0 }, + ]; + // Encode, then re-chunk the bytes into tiny pieces to force the + // decoder to buffer partial lines. + const encoded = await collect(encodeExecEvents(streamOf(events))); + const joined = new Uint8Array(encoded.reduce((n, c) => n + c.byteLength, 0)); + let off = 0; + for (const c of encoded) { + joined.set(c, off); + off += c.byteLength; + } + const tiny = streamOf(Array.from(joined, (b) => new Uint8Array([b]))); + const out = await collect(decodeExecEvents<"utf8">(tiny)); + expect(out).toEqual(events); + }); + + it("emits an empty event stream for an empty byte stream", async () => { + const out = await collect(decodeExecEvents(streamOf([]))); + expect(out).toEqual([]); + }); +}); diff --git a/packages/workspace/src/exec-wire.ts b/packages/workspace/src/exec-wire.ts new file mode 100644 index 00000000..8c6e537c --- /dev/null +++ b/packages/workspace/src/exec-wire.ts @@ -0,0 +1,132 @@ +// Exec event wire codec. +// +// The host-side ExecHandle is a ReadableStream. +// Workers RPC carries byte streams with flow control but not an +// arbitrary object stream, so to project a faithful streaming exec +// across the boundary the event stream is framed as JSONL bytes on +// the durable-object side and parsed back into events on the Worker +// side. +// +// Wire shape: one JSON object per line (newline-delimited), each the +// encoding of a single event: +// +// {"id","seq","name":"stdout","enc":"utf8","value":"..."} string chunk +// {"id","seq","name":"stdout","enc":"b64","value":"..."} byte chunk +// {"id","seq","name":"exit","value":0} exit code +// +// stdout/stderr values are either utf8 text (encoding: "utf8" execs) +// or raw bytes (default execs). Bytes are base64'd so the frame stays +// valid JSON regardless of the chunk's contents. Text is carried as a +// JSON string, which already escapes newlines and quotes, so a chunk +// containing newlines can't be confused with the line delimiter. +// +// This adds a serialization pass per chunk. That cost is the price of +// one faithful Workspace interface on both sides of the wire. + +import type { ExecEncoding, WorkspaceExecEvent } from "./shell.js"; + +// JSON-friendly frame for one event. Discriminated the same way as +// WorkspaceExecEvent, plus an `enc` tag on output chunks so the +// decoder knows whether to hand back a string or a Uint8Array. +type ExecFrame = + | { id: string; seq: number; name: "stdout" | "stderr"; enc: "utf8"; value: string } + | { id: string; seq: number; name: "stdout" | "stderr"; enc: "b64"; value: string } + | { id: string; seq: number; name: "exit"; value: number }; + +function toBase64(bytes: Uint8Array): string { + // Chunked to avoid blowing the argument limit on String.fromCharCode + // for large buffers. + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +function fromBase64(text: string): Uint8Array { + const binary = atob(text); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +function frameOf(event: WorkspaceExecEvent): ExecFrame { + if (event.name === "exit") { + return { id: event.id, seq: event.seq, name: "exit", value: event.value }; + } + if (typeof event.value === "string") { + return { id: event.id, seq: event.seq, name: event.name, enc: "utf8", value: event.value }; + } + return { + id: event.id, + seq: event.seq, + name: event.name, + enc: "b64", + value: toBase64(event.value), + }; +} + +function eventOf(frame: ExecFrame): WorkspaceExecEvent { + if (frame.name === "exit") { + return { id: frame.id, seq: frame.seq, name: "exit", value: frame.value }; + } + const value = frame.enc === "utf8" ? frame.value : fromBase64(frame.value); + return { + id: frame.id, + seq: frame.seq, + name: frame.name, + value, + } as WorkspaceExecEvent; +} + +// Frame a stream of exec events as JSONL bytes for the wire. +export function encodeExecEvents( + events: ReadableStream>, +): ReadableStream { + const encoder = new TextEncoder(); + return events.pipeThrough( + new TransformStream, Uint8Array>({ + transform(event, controller) { + controller.enqueue(encoder.encode(`${JSON.stringify(frameOf(event))}\n`)); + }, + }), + ); +} + +// Parse a JSONL byte stream from the wire back into exec events. +// Buffers partial lines so a chunk boundary can fall anywhere. +export function decodeExecEvents( + bytes: ReadableStream, +): ReadableStream> { + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + const emitLine = ( + line: string, + controller: TransformStreamDefaultController>, + ) => { + if (line.length === 0) return; + const frame = JSON.parse(line) as ExecFrame; + controller.enqueue(eventOf(frame) as WorkspaceExecEvent); + }; + return bytes.pipeThrough( + new TransformStream>({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + emitLine(buffer.slice(0, newline), controller); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + } + }, + flush(controller) { + buffer += decoder.decode(); + // A well-formed stream ends each event with a newline, so the + // tail is normally empty. Emit any trailing line defensively. + emitLine(buffer, controller); + buffer = ""; + }, + }), + ); +} From e8be7daf56a22218ab8cb31d98dad79826e5f329 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:40 +0000 Subject: [PATCH 3/8] workspace: one Workspace interface from the worker and the DO Reaching a Workspace from a Worker went through a reduced stub that only exposed exec().result(), while the durable object that owned the Workspace used the full surface. The two diverged, and the worker side could neither stream exec output nor escape interpolated commands. Add getWorkspace, a single front door that returns the same client whether it is handed the durable object itself or the object's stub from a Worker. A withWorkspace mixin constructs the Workspace, stashes it on the instance under a private symbol, and declares the __getWorkspaceStub accessor on the prototype, the only method shape Workers RPC dispatches to. getWorkspace reads the symbol stash when present and falls back to the RPC accessor otherwise, so the durable object needs no hand-written method. shell.exec on the client takes both a tagged template, escaped through sh before the command crosses the wire, and the plain command-plus- options form. The template form defaults to string output; the plain form is unchanged. The handle stub now exposes result(), stream(), and kill(). stream() frames the event stream as JSONL bytes; the client rebuilds a host-shaped ExecHandle from it, decoding lazily so a result()-only caller never starts the byte stream. result() and stream consumption are mutually exclusive, mirroring the host ExecHandle. The exec span stays open until the handle is consumed by either path, so its nesting and exit-code attributes are preserved. --- packages/workspace/src/client.test.ts | 208 ++++++++++++++++++ packages/workspace/src/client.ts | 267 +++++++++++++++++++++++ packages/workspace/src/index.ts | 15 ++ packages/workspace/src/stub.test.ts | 73 +++++++ packages/workspace/src/stub.ts | 231 ++++++++++++++++---- packages/workspace/src/with-workspace.ts | 80 +++++++ 6 files changed, 829 insertions(+), 45 deletions(-) create mode 100644 packages/workspace/src/client.test.ts create mode 100644 packages/workspace/src/client.ts create mode 100644 packages/workspace/src/with-workspace.ts diff --git a/packages/workspace/src/client.test.ts b/packages/workspace/src/client.test.ts new file mode 100644 index 00000000..74dd1627 --- /dev/null +++ b/packages/workspace/src/client.test.ts @@ -0,0 +1,208 @@ +// Unit tests for getWorkspace and the client's shell.exec forms. +// +// Two dispatch paths: a local host carrying the symbol-stashed +// Workspace (getWorkspace(this)), and a remote stub exposing +// __getWorkspaceStub (getWorkspace(env.MyDO.get(id))). Both must yield +// the same client surface and the same shell.exec behavior. These +// tests use fakes for both paths so the dispatch and the local +// escaping are pinned without standing up workerd. + +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; + +import { getWorkspace } from "./client.js"; +import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; +import { Workspace } from "./workspace.js"; + +interface ExecCall { + command: string; + options: Record | undefined; +} + +// A fake shell that records exec calls. Stands in for both +// Workspace.shell (local) and the shell stub (remote) — the client +// only needs `exec(command, options?)`. +function fakeShell(): { + shell: { exec: (c: string, o?: Record) => Promise }; + calls: ExecCall[]; +} { + const calls: ExecCall[] = []; + // A minimal handle satisfying both the local identity rehydrate + // (which passes it through) and the remote rebuild (which calls + // stream()/result()/kill()). stream() emits one JSONL exit frame so + // the rebuilt handle can be iterated. + const makeHandle = () => ({ + result: () => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }), + stream: () => + new ReadableStream({ + start(c) { + c.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ id: "x", seq: 0, name: "exit", value: 0 })}\n`, + ), + ); + c.close(); + }, + }), + kill: () => Promise.resolve(), + }); + return { + calls, + shell: { + exec(command: string, options?: Record) { + calls.push({ command, options }); + return Promise.resolve(makeHandle()); + }, + }, + }; +} + +// Remote path fake: a stub whose getters mirror WorkspaceStub and +// whose __getWorkspaceStub resolves to it. +function fakeRemote(): { + host: WorkspaceStubHost; + calls: ExecCall[]; + disposed: () => boolean; +} { + const { shell, calls } = fakeShell(); + let disposed = false; + const stub = { + fs: { marker: "fs" }, + git: { marker: "git" }, + assets: undefined, + artifacts: { marker: "artifacts" }, + shell, + [Symbol.dispose]() { + disposed = true; + }, + }; + return { + calls, + disposed: () => disposed, + host: { __getWorkspaceStub: () => Promise.resolve(stub as never) }, + }; +} + +// Local path fake: an object carrying a real Workspace under the +// stash symbol, but with its shell swapped for a recording fake so we +// can assert on exec without a backend. +function fakeLocal(): { host: { [WORKSPACE]: Workspace }; calls: ExecCall[] } { + const ws = new Workspace({ storage: new SQLiteTestStorage() }); + const { shell, calls } = fakeShell(); + Object.defineProperty(ws, "shell", { get: () => shell }); + return { calls, host: { [WORKSPACE]: ws } }; +} + +describe("getWorkspace — remote dispatch", () => { + it("calls __getWorkspaceStub and exposes the stub's members", async () => { + const { host } = fakeRemote(); + const ws = await getWorkspace(host); + expect(ws.fs).toEqual({ marker: "fs" }); + expect(ws.git).toEqual({ marker: "git" }); + expect(ws.artifacts).toEqual({ marker: "artifacts" }); + }); + + it("disposing the client disposes the remote stub", async () => { + const { host, disposed } = fakeRemote(); + const ws = await getWorkspace(host); + ws[Symbol.dispose](); + expect(disposed()).toBe(true); + }); +}); + +describe("getWorkspace — local dispatch", () => { + it("delegates to the in-isolate Workspace via the symbol stash", async () => { + const { host, calls } = fakeLocal(); + const ws = await getWorkspace(host); + await ws.shell.exec`cat ${"my file.txt"}`; + expect(calls[0].command).toBe("cat 'my file.txt'"); + }); + + it("does not throw on dispose (the durable object owns the lifecycle)", async () => { + const { host } = fakeLocal(); + const ws = await getWorkspace(host); + expect(() => ws[Symbol.dispose]()).not.toThrow(); + }); +}); + +describe("client shell.exec — tagged template form", () => { + it("escapes interpolated values before they reach the shell", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec`echo ${"x; rm -rf /"}`; + expect(calls[0].command).toBe("echo 'x; rm -rf /'"); + }); + + it("defaults to utf8 string output", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec`ls`; + expect(calls[0].options).toEqual({ encoding: "utf8" }); + }); + + it("quotes each element of an interpolated array", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec`rm ${["a.txt", "b c.txt"]}`; + expect(calls[0].command).toBe("rm a.txt 'b c.txt'"); + }); +}); + +describe("client shell.exec — plain string form", () => { + it("forwards a bare command with no options", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec("npm test"); + expect(calls[0]).toEqual({ command: "npm test", options: undefined }); + }); + + it("forwards options unchanged", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec("npm test", { cwd: "/workspace", backend: "sandbox" }); + expect(calls[0]).toEqual({ + command: "npm test", + options: { cwd: "/workspace", backend: "sandbox" }, + }); + }); + + it("does not escape a plain string command", async () => { + const { host, calls } = fakeRemote(); + const ws = await getWorkspace(host); + await ws.shell.exec("echo 'already quoted'"); + expect(calls[0].command).toBe("echo 'already quoted'"); + }); +}); + +describe("client shell.exec — remote handle rebuild", () => { + it("rebuilds a host-shaped handle: result() returns the run-and-wait result", async () => { + const { host } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = await ws.shell.exec("echo hi"); + const result = await handle.result(); + expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + }); + + it("rebuilds an async-iterable handle that decodes the JSONL event stream", async () => { + const { host } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = await ws.shell.exec("echo hi"); + const events: Array<{ name: string; value: unknown }> = []; + for await (const event of handle as AsyncIterable<{ name: string; value: unknown }>) { + events.push({ name: event.name, value: event.value }); + } + expect(events).toEqual([{ name: "exit", value: 0 }]); + }); + + it("throws if result() is called after the stream has started", async () => { + const { host } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = await ws.shell.exec("echo hi"); + // Start streaming, then attempt result(): the underlying handle is + // single-shot, so this is rejected rather than silently wrong. + const reader = (handle as ReadableStream).getReader(); + await reader.read(); + reader.releaseLock(); + expect(() => (handle as { result(): unknown }).result()).toThrow(/already streaming/); + }); +}); diff --git a/packages/workspace/src/client.ts b/packages/workspace/src/client.ts new file mode 100644 index 00000000..e73552d3 --- /dev/null +++ b/packages/workspace/src/client.ts @@ -0,0 +1,267 @@ +// getWorkspace — one front door to a Workspace, same interface +// whether you call it from the durable object that owns the Workspace +// or from a Worker across RPC. +// +// // inside the owning durable object: +// using ws = await getWorkspace(this); +// +// // from a Worker: +// using ws = await getWorkspace(env.MyDO.get(id)); +// +// The durable object must extend the `withWorkspace(...)` mixin (see +// with-workspace.ts), which stashes the Workspace under a private +// symbol and exposes the `__getWorkspaceStub` prototype method. +// +// `getWorkspace` dispatches on what it's handed: +// +// - Local host (`this`): the symbol stash holds a `Workspace`. +// Detected with `instanceof`, so the decision doesn't depend on +// how a remote proxy answers a symbol read. The client delegates +// straight to the in-isolate Workspace — no serialization. +// +// - Remote stub (`env.MyDO.get(id)`): no local Workspace, so the +// client calls `__getWorkspaceStub()` over RPC and delegates to +// the returned stub. +// +// Both return the same `WorkspaceClient`. The one member that needs +// adapting per path is `shell.exec`, which accepts a tagged template +// (escaped caller-side through `sh`) as well as the plain +// `(command, options?)` form. Escaping has to run caller-side because +// a `TemplateStringsArray`'s `.raw` does not survive structured clone +// over RPC. + +import { decodeExecEvents } from "./exec-wire.js"; +import { type ShellValue, sh } from "./sh.js"; +import type { ExecEncoding, WorkspaceExecEvent } from "./shell.js"; +import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js"; +import { Workspace } from "./workspace.js"; + +// The remote shell handle stub: a result / stream / kill surface +// carried across Workers RPC. +interface RemoteExecHandle { + result(): Promise<{ exitCode: number; stdout: unknown; stderr: unknown }>; + stream(): ReadableStream; + kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise; + [Symbol.dispose]?(): void; +} + +// Rebuild a host-shaped ExecHandle from a remote handle stub. The +// result is a ReadableStream of decoded events with result() and +// kill() tacked on, matching what the local path returns from +// Workspace.shell.exec. +// +// result() and iterating the stream are mutually exclusive, mirroring +// the host ExecHandle: the underlying stub drains a single handle, so +// whichever is used first wins. result() goes through the stub's +// run-and-wait path (which runs the post-exit pull); iterating goes +// through the stub's byte stream (which doesn't). +function rebuildExecHandle(remote: RemoteExecHandle): unknown { + let started = false; + let reader: ReadableStreamDefaultReader> | undefined; + const stream = new ReadableStream>( + { + // Lazy: don't call remote.stream() until the consumer actually + // pulls. A result()-only caller never starts the stream, so the + // stub's single handle is free for its run-and-wait path. + pull: async (controller) => { + if (reader === undefined) { + started = true; + reader = decodeExecEvents(remote.stream()).getReader(); + } + try { + const { value, done } = await reader.read(); + if (done) { + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + controller.error(error); + } + }, + cancel: (reason) => { + void reader?.cancel(reason); + }, + }, + // highWaterMark 0 keeps pull() from firing until a real read, so a + // result()-only caller never trips the "already streaming" guard. + { highWaterMark: 0 }, + ); + const handle = stream as ReadableStream> & { + result(): Promise; + kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise; + }; + Object.defineProperties(handle, { + result: { + value: () => { + if (started) { + throw new Error( + "exec handle already streaming: call result() or iterate the stream, not both", + ); + } + return remote.result(); + }, + enumerable: false, + }, + kill: { + value: (signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP") => remote.kill(signal), + enumerable: false, + }, + }); + return handle; +} + +// The shell half of the client. `exec` takes two forms: +// +// - Tagged template: `exec`cat ${file}``. Interpolated values are +// escaped before the command is built. Defaults to string +// (`utf8`) output — the ergonomic form can't carry options to ask +// for it and a caller almost always wants text. +// +// - Plain `(command, options?)`: forwarded unchanged. Defaults to +// the underlying surface's default. Wrap an interpolated command +// in `sh` to escape it: `exec(sh`cat ${file}`, { cwd })`. +// +// `R` is the handle type the underlying surface returns (the host +// `ExecHandle` locally, the handle stub remotely). +export interface WorkspaceShellClient { + exec(strings: TemplateStringsArray, ...values: ShellValue[]): Promise; + exec(command: string): Promise; + exec(command: string, options: ShellExecOptions & { encoding: "utf8" }): Promise; + exec(command: string, options: ShellExecOptions): Promise; +} + +// Options accepted by the plain `exec` form, common to both paths. +export interface ShellExecOptions { + cwd?: string; + encoding?: "utf8"; + backend?: string; + id?: string; + timeoutMs?: number; +} + +function isTemplateStringsArray(value: unknown): value is TemplateStringsArray { + // A tagged-template call hands the cooked strings as the first + // argument: an array. A plain `exec(command)` call hands a string. + return Array.isArray(value); +} + +// The underlying shell surface both paths expose: an `exec` taking a +// command string and options. Locally this is `Workspace.shell`; +// remotely it's the shell stub. +interface UnderlyingShell { + // biome-ignore lint/suspicious/noExplicitAny: bridges two concrete exec overload sets + exec(command: string, options?: Record): Promise; +} + +function makeShellClient( + shell: UnderlyingShell, + // Adapts the handle the underlying `exec` resolves to: identity on + // the local path (already a host ExecHandle), rebuild on the remote + // path (a handle stub that needs inflating from its JSONL stream). + rehydrate: (handle: unknown) => unknown, + // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path +): WorkspaceShellClient { + async function exec( + commandOrStrings: string | TemplateStringsArray, + optionsOrValue?: ShellExecOptions | ShellValue, + ...rest: ShellValue[] + // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path + ): Promise { + if (isTemplateStringsArray(commandOrStrings)) { + const values = optionsOrValue === undefined ? rest : [optionsOrValue as ShellValue, ...rest]; + const command = sh(commandOrStrings, ...values); + return rehydrate(await shell.exec(command, { encoding: "utf8" })); + } + const options = optionsOrValue as ShellExecOptions | undefined; + const handle = + options === undefined + ? await shell.exec(commandOrStrings) + : await shell.exec(commandOrStrings, options as Record); + return rehydrate(handle); + } + // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path + return { exec } as WorkspaceShellClient; +} + +// The canonical client surface. `shell.exec` is the adapted member; +// `fs`, `git`, `artifacts`, and `assets` are the underlying surface's +// members, passed through. Their concrete types differ between the +// local Workspace and the remote stub, so they're surfaced loosely +// here and narrowed by the caller when needed. +export interface WorkspaceClient { + // biome-ignore lint/suspicious/noExplicitAny: fs type differs local vs remote + readonly fs: any; + // biome-ignore lint/suspicious/noExplicitAny: handle types differ per path + readonly shell: WorkspaceShellClient; + // biome-ignore lint/suspicious/noExplicitAny: git type differs local vs remote + readonly git: any; + // biome-ignore lint/suspicious/noExplicitAny: assets type differs local vs remote + readonly assets: any; + // biome-ignore lint/suspicious/noExplicitAny: artifacts type differs local vs remote + readonly artifacts: any; + [Symbol.dispose](): void; +} + +function makeClient( + // biome-ignore lint/suspicious/noExplicitAny: underlying surface differs per path + surface: any, + rehydrate: (handle: unknown) => unknown, + dispose: () => void, +): WorkspaceClient { + const shell = makeShellClient(surface.shell as UnderlyingShell, rehydrate); + return { + get fs() { + return surface.fs; + }, + shell, + get git() { + return surface.git; + }, + get assets() { + return surface.assets; + }, + get artifacts() { + return surface.artifacts; + }, + [Symbol.dispose]: dispose, + }; +} + +// What `getWorkspace` accepts: a local host carrying the symbol stash +// (the durable object `this`), or a remote stub exposing +// `__getWorkspaceStub`. +export type WorkspaceHandle = { [WORKSPACE]?: unknown } | WorkspaceStubHost; + +export async function getWorkspace(handle: WorkspaceHandle): Promise { + const local = (handle as { [WORKSPACE]?: unknown })[WORKSPACE]; + if (local instanceof Workspace) { + // Local path: delegate straight to the in-isolate Workspace. + // Nothing to dispose — the durable object owns the Workspace + // lifecycle. + await local.ready(); + return makeClient( + { + fs: local.fs, + shell: local.shell, + git: local.git, + artifacts: local.artifacts, + assets: local.assets, + }, + // Local handle is already a host ExecHandle — pass it through. + (h) => h, + () => {}, + ); + } + // Remote path: fetch the stub over RPC and delegate to it. Handle + // stubs need inflating from their JSONL stream into a host-shaped + // ExecHandle. + const stub = await (handle as WorkspaceStubHost).__getWorkspaceStub(); + return makeClient( + stub, + (h) => rebuildExecHandle(h as RemoteExecHandle), + () => { + (stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + }, + ); +} diff --git a/packages/workspace/src/index.ts b/packages/workspace/src/index.ts index 32705e8a..216cadb8 100644 --- a/packages/workspace/src/index.ts +++ b/packages/workspace/src/index.ts @@ -24,6 +24,14 @@ export type { export { SQLiteWorkspaceProvider } from "@cloudflare/dofs"; export type { BackendHandle, WorkspaceBackend } from "./backend.js"; export { TestBackend, type TestBackendOptions } from "./backends/test.js"; +export { + getWorkspace, + type ShellExecOptions, + type WorkspaceClient, + type WorkspaceHandle, + type WorkspaceShellClient, +} from "./client.js"; +export { decodeExecEvents, encodeExecEvents } from "./exec-wire.js"; export { R2Bucket, type R2BucketBinding, type R2BucketOptions } from "./mounts/providers/r2.js"; export type { EagerMount, @@ -47,6 +55,7 @@ export { WorkspaceServiceProxy, type WorkspaceServiceProxyProps, } from "./proxy.js"; +export { type RawShellValue, type ShellValue, sh, shellQuote } from "./sh.js"; export type { ExecEncoding, ExecHandle, @@ -67,4 +76,10 @@ export { WorkspaceShellStub, WorkspaceStub, } from "./stub.js"; +export { + type WithWorkspaceCtor, + type WorkspaceLocalHost, + type WorkspaceStubHost, + withWorkspace, +} from "./with-workspace.js"; export { Workspace, type WorkspaceOptions } from "./workspace.js"; diff --git a/packages/workspace/src/stub.test.ts b/packages/workspace/src/stub.test.ts index bdc4a0f1..3245c00e 100644 --- a/packages/workspace/src/stub.test.ts +++ b/packages/workspace/src/stub.test.ts @@ -16,6 +16,7 @@ import { enableStubTracking, stubSnapshot } from "@cloudflare/workspace-rpc/debu import { beforeAll, describe, expect, it } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; +import { decodeExecEvents } from "./exec-wire.js"; import { WorkspaceAssetsStub, WorkspaceExecHandleStub, @@ -376,4 +377,76 @@ describe("WorkspaceStub", () => { { backend: backend({ shell: shellRpc }) }, ); }); + + it("shell.exec handle.stream() frames events as JSONL that decode back", async () => { + // The handle's stream() projects the event stream as JSONL bytes + // for the wire. Decoding it back yields the original events, + // including a binary stdout chunk carried base64. + const payload = new Uint8Array([0x00, 0xff, 0x41]); + const shellRpc: import("@cloudflare/workspace-rpc").ShellRPC = { + async exec() { + return { + id: "e-1", + events: new ReadableStream({ + start(c) { + c.enqueue({ id: "e-1", seq: 1, name: "stdout", value: payload }); + c.enqueue({ id: "e-1", seq: 2, name: "exit", value: 0 }); + c.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: () => Promise.reject(new Error("not used")), + }; + await withStub( + async (ws) => { + const stub = ws.stub(); + const handle = await stub.shell.exec("noop"); + const events: Array<{ name: string; value: unknown }> = []; + const decoded = decodeExecEvents(handle.stream()); + const reader = decoded.getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + events.push({ name: value.name, value: value.value }); + } + reader.releaseLock(); + expect(events).toHaveLength(2); + expect(events[0].name).toBe("stdout"); + expect(Array.from(events[0].value as Uint8Array)).toEqual(Array.from(payload)); + expect(events[1]).toEqual({ name: "exit", value: 0 }); + }, + { backend: backend({ shell: shellRpc }) }, + ); + }); + + it("shell.exec handle rejects result() after stream() has claimed it", async () => { + const shellRpc: import("@cloudflare/workspace-rpc").ShellRPC = { + async exec() { + return { + id: "e-1", + events: new ReadableStream({ + start(c) { + c.enqueue({ id: "e-1", seq: 1, name: "exit", value: 0 }); + c.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: () => Promise.reject(new Error("not used")), + }; + await withStub( + async (ws) => { + const stub = ws.stub(); + const handle = await stub.shell.exec("noop"); + handle.stream(); + await expect(handle.result()).rejects.toThrow(/single-shot/); + }, + { backend: backend({ shell: shellRpc }) }, + ); + }); }); diff --git a/packages/workspace/src/stub.ts b/packages/workspace/src/stub.ts index 0b796807..1c559e0e 100644 --- a/packages/workspace/src/stub.ts +++ b/packages/workspace/src/stub.ts @@ -28,12 +28,12 @@ // because Workers RPC doesn't carry non-byte ReadableStreams or // capnweb stubs. // -// Streaming exec is intentionally absent from this surface for -// now. Workers RPC only carries ReadableStream, so a -// streamed exec would have to frame events as bytes (SSE, length- -// prefixed JSON, etc.) — punted until we have a concrete caller -// that needs it. Today exec() returns a handle whose only method -// is result(), matching the run-and-wait half of WorkspaceShell. +// Streaming exec crosses the boundary as bytes. Workers RPC only +// carries ReadableStream, so the exec handle's event +// stream is framed as JSONL bytes by handle.stream() and inflated +// back into events on the Worker side (see exec-wire.ts and the +// getWorkspace client). The handle also exposes result() (run-and- +// wait) and kill(). // // RpcTarget comes from capnweb rather than `cloudflare:workers`. // Per capnweb's docs, that import is an alias for the workerd @@ -67,9 +67,11 @@ import type { ArtifactsCLIResult, } from "./artifacts/index.js"; import type { ShareOptions } from "./assets/index.js"; +import { encodeExecEvents } from "./exec-wire.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; -import type { ExecResult } from "./shell.js"; +import { assertNotTemplate } from "./sh.js"; +import type { ExecEncoding, ExecHandle, WorkspaceExecEvent } from "./shell.js"; import type { Workspace } from "./workspace.js"; export interface WorkspaceExecOptions { @@ -248,39 +250,150 @@ export class WorkspaceFilesystemStub extends RpcTarget { } } +// How the handle was consumed, fed back to the exec span so it can +// record the exit code. result() carries the full outcome; stream() +// carries the exit code observed on the wire and zeroes the sync +// counts (raw stream consumption skips the post-exit pull, matching +// the host ExecHandle contract). +interface ConsumeOutcome { + exitCode: number; + pushed: number; + pulled: number; + skippedCount: number; +} + +// A deferred the exec span awaits. Resolving it (via result(), +// stream(), or dispose) lets the span close and record its outcome. +interface Consumer { + promise: Promise; + resolve(outcome: ConsumeOutcome): void; +} + +function makeConsumer(): Consumer { + let resolve!: (outcome: ConsumeOutcome) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + // Exec handle returned from WorkspaceShellStub.exec. Holds the -// underlying ExecHandle on the DO side and exposes only the -// run-and-wait half of its API — result() — because Workers RPC -// can't carry the non-byte event stream that ExecHandle is. +// underlying host ExecHandle and projects it across Workers RPC: +// +// - result() drains the handle and returns the run-and-wait result. +// - stream() returns the event stream framed as JSONL bytes — the +// one shape Workers RPC carries — for run-and-stream callers. The +// client side inflates it back into events. +// - kill() forwards a signal to the running command. // -// kill() and event streaming are deliberately omitted for now; -// they'd need a byte-framed transport (SSE, length-prefixed -// JSON) and we don't have a caller for that yet. When that lands -// it goes here as a new method, not as a replacement for this -// one. +// result() and stream() are mutually exclusive: each drains the +// single underlying handle, so the first one called wins and the +// other throws (the host ExecHandle is a one-shot ReadableStream). +// This mirrors the host contract exactly. export class WorkspaceExecHandleStub extends RpcTarget { - readonly #pending: Promise>; - - constructor(pending: Promise>) { + readonly #handle: Promise>; + readonly #consumer: Consumer; + // Resolves when the exec span has closed (finalize has run). result() + // awaits it so the span's attributes are set before result() returns + // — observers see a complete span synchronously after the await. + readonly #span: Promise; + #consumed = false; + + constructor(handle: Promise>, consumer: Consumer, span: Promise) { super(); - this.#pending = pending; + this.#handle = handle; + this.#consumer = consumer; + this.#span = span; trackStub(this); } [Symbol.dispose](): void { + // If neither result() nor stream() ran, release the exec span so + // it doesn't hang open, and cancel the handle's stream so wsd + // stops the command rather than buffering forever. + if (!this.#consumed) { + this.#consumed = true; + this.#consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); + this.#handle + .then((handle) => handle.cancel?.()) + .catch(() => { + // best effort — nothing to do if the handle never resolved + }); + } untrackStub(this); } + #claim(): void { + if (this.#consumed) { + throw new Error("exec handle already consumed: result() and stream() are single-shot"); + } + this.#consumed = true; + } + async result(): Promise> { - const result = await this.#pending; - return { - exitCode: result.exitCode, - // joinParts in shell.ts returns string for "utf8", - // Uint8Array otherwise — exactly the - // WorkspaceExecResult shape. - stdout: result.stdout as WorkspaceExecResult["stdout"], - stderr: result.stderr as WorkspaceExecResult["stderr"], - }; + this.#claim(); + try { + const handle = await this.#handle; + const result = await handle.result(); + this.#consumer.resolve({ + exitCode: result.exitCode, + pushed: result.pushed, + pulled: result.pulled, + skippedCount: result.skipped.length, + }); + // Let the span close before returning so its attributes are set. + await this.#span.catch(() => {}); + return { + exitCode: result.exitCode, + // joinParts in shell.ts returns string for "utf8", + // Uint8Array otherwise — exactly the + // WorkspaceExecResult shape. + stdout: result.stdout as WorkspaceExecResult["stdout"], + stderr: result.stderr as WorkspaceExecResult["stderr"], + }; + } catch (error) { + this.#consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); + throw error; + } + } + + // Event stream framed as JSONL bytes for the wire. The client + // decodes it back into WorkspaceExecEvents. Raw stream consumption + // skips the post-exit pull, so the exec span records zero sync + // counts; the exit code is captured off the wire as it passes. + stream(): ReadableStream { + this.#claim(); + const consumer = this.#consumer; + let exitCode = -1; + const source = new ReadableStream>({ + start: async (controller) => { + try { + const handle = await this.#handle; + const reader = (handle as ReadableStream>).getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value.name === "exit") exitCode = value.value; + controller.enqueue(value); + } + } finally { + reader.releaseLock(); + } + controller.close(); + consumer.resolve({ exitCode, pushed: 0, pulled: 0, skippedCount: 0 }); + } catch (error) { + consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); + controller.error(error); + } + }, + }); + return encodeExecEvents(source as ReadableStream>); + } + + async kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise { + const handle = await this.#handle; + await handle.kill(signal); } } @@ -438,6 +551,12 @@ export class WorkspaceShellStub extends RpcTarget { command: string, options: WorkspaceExecOptions = {}, ): Promise> { + // A worker that calls this as a tagged template ships a + // TemplateStringsArray over Workers RPC, which loses its `.raw` + // property to structured clone before it lands here. Reject it + // so the unsafe path fails loudly; escaping belongs caller-side + // through sh`...`. + assertNotTemplate(command); // Heal a torn-down session before reaching for the shell. The // backend's `closed` listener (see workspace.ts) clears #handle, // #shell, and #readyPromise on a mid-session transport drop, so @@ -450,14 +569,29 @@ export class WorkspaceShellStub extends RpcTarget { // Kick off the exec eagerly so the caller's first round trip // (the one that built this stub) already has the spawn in - // flight. result() awaits the handle's own result() when the - // caller asks. + // flight. The handle is consumed later by the returned stub's + // result() or stream(). // - // The whole bracket runs inside one `workspace.shell.exec` span - // so the pre-exec push, the spawn, and the post-drain pull nest - // underneath it on the observer's active context. Errors from - // either side land on this span. - const pending: Promise> = withSpan( + // The bracket runs inside one `workspace.shell.exec` span. The + // span stays open until the handle is consumed: the spawn runs + // inside the callback (so `workspace.shell.exec.spawn` and the + // pre-exec push nest under it), then the callback awaits the + // consumer deferred, which the stub resolves from result() / + // stream() / dispose. Because the recorder keeps the span on its + // stack across that await, the post-drain pull triggered by + // result() also nests under this span. + const consumer = makeConsumer(); + let resolveHandle!: (handle: ExecHandle<"utf8" | undefined>) => void; + let rejectHandle!: (error: unknown) => void; + const handle = new Promise>((resolve, reject) => { + resolveHandle = resolve; + rejectHandle = reject; + }); + // Swallow rejections on the convenience promise; the real + // rejection is delivered to whoever awaits the handle. + handle.catch(() => {}); + + const span = withSpan( this.#ws.observer, "workspace.shell.exec", { @@ -465,27 +599,34 @@ export class WorkspaceShellStub extends RpcTarget { "workspace.shell.encoding": options.encoding, "workspace.shell.backend": options.backend, }, - () => - options.encoding === "utf8" - ? this.#ws.shell - .exec(command, { + async () => { + const spawned = + options.encoding === "utf8" + ? await this.#ws.shell.exec(command, { cwd: options.cwd, encoding: "utf8", backend: options.backend, }) - .then((handle) => handle.result()) - : this.#ws.shell - .exec(command, { cwd: options.cwd, backend: options.backend }) - .then((handle) => handle.result()), + : await this.#ws.shell.exec(command, { + cwd: options.cwd, + backend: options.backend, + }); + resolveHandle(spawned as ExecHandle<"utf8" | undefined>); + return consumer.promise; + }, (span, outcome) => { if (!outcome.ok) return; span.setAttribute("workspace.shell.exit_code", outcome.value.exitCode); span.setAttribute("workspace.shell.pushed", outcome.value.pushed); span.setAttribute("workspace.shell.pulled", outcome.value.pulled); - span.setAttribute("workspace.shell.skipped", outcome.value.skipped.length); + span.setAttribute("workspace.shell.skipped", outcome.value.skippedCount); }, ); - return new WorkspaceExecHandleStub<"utf8" | undefined>(pending); + // If the spawn throws, the span rejects: forward the failure to + // the handle so result() / stream() reject, and keep the span + // promise from surfacing as an unhandled rejection. + span.catch((error) => rejectHandle(error)); + return new WorkspaceExecHandleStub<"utf8" | undefined>(handle, consumer, span); } } diff --git a/packages/workspace/src/with-workspace.ts b/packages/workspace/src/with-workspace.ts new file mode 100644 index 00000000..d3278f7e --- /dev/null +++ b/packages/workspace/src/with-workspace.ts @@ -0,0 +1,80 @@ +// withWorkspace mixin. +// +// A durable object class extends `withWorkspace(Base, options)` to +// own a Workspace without hand-writing any wiring. The mixin: +// +// - constructs the Workspace from the options the callback returns +// (the callback receives the instance, so it can read `ctx` / +// `env` after `super(...)` has run); +// - stashes the Workspace on the instance under a module-private +// symbol, so it's reachable by `getWorkspace(this)` in-isolate +// but invisible to same-isolate property pokes and to Workers +// RPC (symbol-keyed instance properties don't cross the wire); +// - declares `__getWorkspaceStub()` in the class body so it lands +// on the prototype, which is the only method shape Workers RPC +// dispatches to. That's the door `getWorkspace(stub)` uses from a +// Worker. +// +// export class MyDO extends withWorkspace( +// class extends DurableObject {}, +// (self) => ({ +// storage: self.ctx.storage, +// sessionId: self.ctx.id.toString(), +// backends: [/* ... */], +// }), +// ) {} +// +// Reach the Workspace through `getWorkspace` (see client.ts), the +// same way from inside the durable object (`getWorkspace(this)`) and +// from a Worker (`getWorkspace(env.MyDO.get(id))`). + +import { Workspace, type WorkspaceOptions } from "./workspace.js"; + +// Module-private stash key. Never user-facing; never serialized. +export const WORKSPACE = Symbol("workspace"); + +// The prototype method `getWorkspace(stub)` calls over RPC. Exported +// so the client and its tests can name the shape. +export interface WorkspaceStubHost { + __getWorkspaceStub(): Promise; +} + +// A host that carries the symbol-stashed Workspace. Used by the +// local `getWorkspace(this)` path. +export interface WorkspaceLocalHost { + [WORKSPACE]: Workspace; +} + +// biome-ignore lint/suspicious/noExplicitAny: mixin constructor shape requires any[] +type DOCtor = new (...args: any[]) => object; + +// Constructor type the mixin returns. Written out so +// rolldown-plugin-dts can emit a stable declaration. +export type WithWorkspaceCtor = TBase & + (new ( + // biome-ignore lint/suspicious/noExplicitAny: mirror mixin constructor shape + ...args: any[] + ) => InstanceType & WorkspaceStubHost & WorkspaceLocalHost); + +export function withWorkspace( + Base: TBase, + options: (self: InstanceType) => WorkspaceOptions, +): WithWorkspaceCtor { + class WithWorkspace extends Base { + // biome-ignore lint/suspicious/noExplicitAny: mixin constructor shape requires any[] + constructor(...args: any[]) { + super(...args); + const self = this as unknown as InstanceType; + (this as unknown as WorkspaceLocalHost)[WORKSPACE] = new Workspace(options(self)); + } + + __getWorkspaceStub(): Promise { + const ws = (this as unknown as WorkspaceLocalHost)[WORKSPACE]; + return (async () => { + await ws.ready(); + return ws.stub(); + })(); + } + } + return WithWorkspace as WithWorkspaceCtor; +} From 2977a5b8f4b72b822fe00cbd3fbfaed68fb43ae4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:55 +0000 Subject: [PATCH 4/8] examples/artifacts: use the withWorkspace mixin and getWorkspace Extend the withWorkspace mixin instead of constructing the Workspace and hand-writing a getWorkspace() method on the durable object. Reach the Workspace from the endpoint through getWorkspace(stub), and build shell commands with the sh tag rather than a local quoting helper. The local shellQuote helper is gone. --- examples/artifacts/src/index.ts | 102 ++++++++++++++------------------ 1 file changed, 43 insertions(+), 59 deletions(-) diff --git a/examples/artifacts/src/index.ts b/examples/artifacts/src/index.ts index 57fc84db..23004848 100644 --- a/examples/artifacts/src/index.ts +++ b/examples/artifacts/src/index.ts @@ -4,16 +4,19 @@ // examples/worker, rewrites its Worker name, publishes it to a new // Cloudflare Artifacts repo, and returns a read-only clone URL. // The Worker owns the endpoint logic. The durable object stays -// minimal: it owns the Workspace, exposes getWorkspace(), and bridges -// the host Artifacts binding into the worker-backend shell command. +// minimal: it constructs the Workspace with `this` so the Worker can +// reach it through getWorkspace(stub), and bridges the host Artifacts +// binding into the worker-backend shell command. import { DurableObject } from "cloudflare:workers"; import { type DurableObjectStorageLike, - Workspace, + getWorkspace, + sh, + type WorkspaceClient, WorkspaceServiceProxy, - type WorkspaceStub, + withWorkspace, } from "@cloudflare/workspace"; import { WorkerBackend, type WorkerBackendOptions } from "@cloudflare/workspace/backends/worker"; @@ -46,29 +49,24 @@ const EXAMPLE_PATH = "examples/worker"; const GIT_REMOTE = "origin"; const SHARE_TOKEN_TTL = "24h"; -export class ArtifactCreator extends DurableObject { - readonly #workspace: Workspace; - - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - const workerBackendOptions: WorkerBackendOptions = { - loader: env.LOADER as unknown as WorkerBackendOptions["loader"], - workspace: { binding: "ArtifactCreator", id: ctx.id.toString() }, - ctx, - }; - this.#workspace = new Workspace({ - storage: ctx.storage as unknown as DurableObjectStorageLike, - sessionId: ctx.id.toString(), - artifacts: { binding: env.ARTIFACTS }, - backends: [new WorkerBackend(workerBackendOptions)], - }); - } - - async getWorkspace(): Promise { - await this.#workspace.ready(); - return this.#workspace.stub(); - } -} +// Extending `withWorkspace` gives the durable object a Workspace and +// the plumbing `getWorkspace` needs, with no hand-written method. The +// callback runs after `super(...)`, so it can read `self.ctx` / +// `self.env`. +export class ArtifactCreator extends withWorkspace(class extends DurableObject {}, (self) => { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + const workerBackendOptions: WorkerBackendOptions = { + loader: env.LOADER as unknown as WorkerBackendOptions["loader"], + workspace: { binding: "ArtifactCreator", id: ctx.id.toString() }, + ctx, + }; + return { + storage: ctx.storage as unknown as DurableObjectStorageLike, + sessionId: ctx.id.toString(), + artifacts: { binding: env.ARTIFACTS }, + backends: [new WorkerBackend(workerBackendOptions)], + }; +}) {} export default { async fetch(request: Request, env: Env): Promise { @@ -125,12 +123,10 @@ async function handleCreate(request: Request, env: Env): Promise { } const stub = env.ArtifactCreator.get(env.ArtifactCreator.idFromName(name)); - // `wrangler types` exposes returned RpcTarget instances as - // Rpc.Stub, which loses the concrete overloads on nested - // members. Runtime-wise this is still the WorkspaceStub surface, - // so cast once at the boundary and keep the rest of the example - // readable. - const ws = (await stub.getWorkspace()) as unknown as WorkspaceStub; + // `wrangler types` doesn't surface the `__getWorkspaceStub` accessor + // the `withWorkspace` mixin installs, so cast once at the boundary + // and keep the rest readable. + const ws = await getWorkspace(stub as unknown as Parameters[0]); const sourceDir = `${WORKSPACE_ROOT}/${name}-source`; const projectDir = `${WORKSPACE_ROOT}/${name}`; @@ -138,16 +134,16 @@ async function handleCreate(request: Request, env: Env): Promise { await exec( ws, [ - `rm -rf ${shellQuote(sourceDir)} ${shellQuote(projectDir)}`, - `git clone --depth 1 ${shellQuote(SOURCE_REPO)} ${shellQuote(sourceDir)}`, - `mkdir -p ${shellQuote(projectDir)}`, - `cp -R ${shellQuote(`${sourceDir}/${EXAMPLE_PATH}/.`)} ${shellQuote(projectDir)}`, - `sed -i ${shellQuote(`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "${name}"/`)} ${shellQuote(`${projectDir}/wrangler.jsonc`)}`, - `sed -i ${shellQuote(`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "@example\\/${name}"/`)} ${shellQuote(`${projectDir}/package.json`)}`, - `git init --initial-branch=main ${shellQuote(projectDir)}`, - `cat ${shellQuote(`${projectDir}/.git/HEAD`)} >/dev/null`, + sh`rm -rf ${sourceDir} ${projectDir}`, + sh`git clone --depth 1 ${SOURCE_REPO} ${sourceDir}`, + sh`mkdir -p ${projectDir}`, + sh`cp -R ${`${sourceDir}/${EXAMPLE_PATH}/.`} ${projectDir}`, + sh`sed -i ${`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "${name}"/`} ${`${projectDir}/wrangler.jsonc`}`, + sh`sed -i ${`s/"name"[[:space:]]*:[[:space:]]*"[^"]*"/"name": "@example\\/${name}"/`} ${`${projectDir}/package.json`}`, + sh`git init --initial-branch=main ${projectDir}`, + sh`cat ${`${projectDir}/.git/HEAD`} >/dev/null`, "git add .", - `git commit -m ${shellQuote(`Create ${name} worker example`)} --author ${shellQuote("Cloudflare Workspace Artifacts Example ")}`, + sh`git commit -m ${`Create ${name} worker example`} --author ${"Cloudflare Workspace Artifacts Example "}`, ].join(" && "), { cwd: projectDir }, ); @@ -162,19 +158,12 @@ async function handleCreate(request: Request, env: Env): Promise { const created = parseJSON( await exec( ws, - [ - "artifacts create", - shellQuote(name), - `--remote ${GIT_REMOTE}`, - "--force", - "--default-branch main", - `--description ${shellQuote(`Generated from ${SOURCE_REPO}/${EXAMPLE_PATH}`)}`, - ].join(" "), + sh`artifacts create ${name} --remote ${GIT_REMOTE} --force --default-branch main --description ${`Generated from ${SOURCE_REPO}/${EXAMPLE_PATH}`}`, { cwd: projectDir }, ), ); - await exec(ws, `git push --force ${shellQuote(GIT_REMOTE)} HEAD:main`, { + await exec(ws, sh`git push --force ${GIT_REMOTE} HEAD:main`, { cwd: projectDir, secretToRedact: created.credentialedRemote, }); @@ -183,7 +172,7 @@ async function handleCreate(request: Request, env: Env): Promise { // clone-ready URL, so there is nothing to hand-assemble. The URL // carries a live token — redact it from any error output. const shareLink = ( - await exec(ws, `artifacts share ${shellQuote(name)} --scope read --ttl ${SHARE_TOKEN_TTL}`) + await exec(ws, sh`artifacts share ${name} --scope read --ttl ${SHARE_TOKEN_TTL}`) ).trim(); return Response.json({ @@ -193,7 +182,7 @@ async function handleCreate(request: Request, env: Env): Promise { branch: "main", projectDir, shareLink, - cloneCommand: `git clone ${shellQuote(shareLink)} ${shellQuote(name)}`, + cloneCommand: sh`git clone ${shareLink} ${name}`, } satisfies CreateResult); } catch (cause) { return errorJSON(cause, isAlreadyExists(cause) ? 409 : 500); @@ -203,7 +192,7 @@ async function handleCreate(request: Request, env: Env): Promise { } async function exec( - ws: WorkspaceStub, + ws: WorkspaceClient, command: string, options: { cwd?: string; secretToRedact?: string } = {}, ): Promise { @@ -243,8 +232,3 @@ function errorJSON(error: unknown, status: number): Response { const code = (error as { code?: string }).code; return Response.json({ error: message, code }, { status }); } - -function shellQuote(arg: string): string { - if (/^[A-Za-z0-9_\-+=:,./@%]+$/.test(arg)) return arg; - return `'${arg.replace(/'/g, `'"'"'`)}'`; -} From fcb7589bf8946f1d5cb91e22285c041d2b0fd5b4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:55:11 +0000 Subject: [PATCH 5/8] docs: document getWorkspace, the sh tag, and streaming exec Describe reaching a Workspace through getWorkspace from both a Worker and the owning durable object, building commands with the sh tagged template and the plain exec form, and why escaping runs on the caller's side. Update the README durable-object examples to the withWorkspace mixin. --- docs/05_shell_interface.md | 73 ++++++++++++++++++++++ packages/workspace/README.md | 115 +++++++++++++++++++++++++---------- 2 files changed, 157 insertions(+), 31 deletions(-) diff --git a/docs/05_shell_interface.md b/docs/05_shell_interface.md index 9eadd43c..0addbfd6 100644 --- a/docs/05_shell_interface.md +++ b/docs/05_shell_interface.md @@ -183,6 +183,79 @@ await run.kill(); // SIGTERM await run.kill("SIGKILL"); ``` +## Building commands safely + +`exec` takes one command string, and the container runs it through +`/bin/sh -c`. Interpolating a value straight into that string is a +shell-injection risk: a path or branch name like `x; rm -rf /` breaks +out of its argument. + +Reach the Workspace through `getWorkspace` and call `shell.exec` as a +tagged template. Interpolated values are escaped before the command +runs. `getWorkspace` takes either the durable object stub from a +Worker (`getWorkspace(env.MyDO.get(id))`) or the durable object itself +from inside it (`getWorkspace(this)`); the surface is identical both +ways: + +```ts +import { getWorkspace } from "@cloudflare/workspace"; + +using ws = await getWorkspace(env.MyDO.get(id)); + +const file = "my notes.md"; +const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md' +``` + +Strings and numbers are quoted, arrays are quoted element-by-element +and joined with spaces, and the literal parts of the template (the +trusted command) are emitted verbatim. The tagged-template form +defaults to string (`utf8`) output, since a caller reaching for it +almost always wants text back. + +The plain `exec(command, options)` form is unchanged and still +available. Use it when you need `cwd` or `backend`, and wrap an +interpolated command in the `sh` tag to escape it: + +```ts +import { sh } from "@cloudflare/workspace"; + +await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" }); +await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" }); +``` + +The plain form defaults to `Uint8Array` output; pass +`{ encoding: "utf8" }` for a string. + +`sh` is exported on its own for composing a command string — the +building block both the tagged-template `exec` and the +`exec(sh`...`, options)` form use. Its escaping rules: strings and +numbers are quoted, arrays are quoted element-by-element, and the +static parts come from `strings.raw` so a backslash you write in the +template reaches the shell as written. To splice in deliberate shell +syntax — a pipe, a redirect, a pre-quoted sub-command — wrap the value +in `{ raw: "..." }` to opt out of escaping for that one value: + +```ts +await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`); +``` + +The single-argument quoter `shellQuote` is exported for cases that +don't fit a template. + +### Why escaping runs caller-side + +`sh` collapses a template to a finished string before the call +because of the RPC boundary. When a Worker calls `exec` through the +Workspace stub, the command crosses Workers RPC as a value and is run +on the durable-object side. A `TemplateStringsArray` does not survive +that trip intact — structured clone keeps the indexed string parts but +drops the `.raw` property the escaping relies on. So the escaping has +to happen in the caller, which is what `getWorkspace`'s client does +for the tagged-template form and what `sh` does explicitly. The +remote stub's `exec` rejects a raw tagged-template call with a +`TypeError` rather than run an unescaped command, so the unsafe path +fails loudly. + ## Working directory `cwd` is optional and defaults to the workspace root (see diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 7bf386ca..0d0cc095 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -74,59 +74,54 @@ uniform; the counts are just always zero. Container backend: ```ts -import { Workspace, WorkspaceProxy } from "@cloudflare/workspace"; +import { withWorkspace, WorkspaceProxy } from "@cloudflare/workspace"; import { CloudflareContainerBackend, withWorkspaceContainer } from "@cloudflare/workspace/backends/container"; import { DurableObject } from "cloudflare:workers"; export { WorkspaceProxy }; -export class ContainerExample extends withWorkspaceContainer(class extends DurableObject {}) { - #workspace = new Workspace({ - storage: this.ctx.storage, +// `withWorkspace` constructs the Workspace and installs the plumbing +// `getWorkspace` needs — no hand-written stub method. The options +// callback runs after `super(...)`, so it can read `self.ctx`. Compose +// it with `withWorkspaceContainer` when the durable object also owns +// the container binding. +export class ContainerExample extends withWorkspace( + withWorkspaceContainer(class extends DurableObject {}), + (self) => ({ + storage: self.ctx.storage, backends: [ new CloudflareContainerBackend({ - container: () => this, - workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, + container: () => self, + workspace: { binding: "ContainerExample", id: self.ctx.id.toString() }, }), ], - }); - - async getWorkspace(): Promise { - await this.#workspace.ready(); - return this.#workspace.stub(); - } - - override fetch(req: Request) { return this.#workspace; /* see example */ } -} + }), +) {} ``` Worker backend: ```ts -import { Workspace, WorkspaceServiceProxy } from "@cloudflare/workspace"; +import { withWorkspace, WorkspaceServiceProxy } from "@cloudflare/workspace"; import { WorkerBackend } from "@cloudflare/workspace/backends/worker"; import { DurableObject } from "cloudflare:workers"; export { WorkspaceServiceProxy }; -export class ContainerExample extends DurableObject { - #workspace = new Workspace({ - storage: this.ctx.storage, +export class ContainerExample extends withWorkspace( + class extends DurableObject {}, + (self) => ({ + storage: self.ctx.storage, backends: [ new WorkerBackend({ - loader: env.LOADER, - workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, - ctx, + loader: self.env.LOADER, + workspace: { binding: "ContainerExample", id: self.ctx.id.toString() }, + ctx: self.ctx, }), ], - }); - - async getWorkspace(): Promise { - await this.#workspace.ready(); - return this.#workspace.stub(); - } -} + }), +) {} ``` Filesystem only — no backend, no shell: @@ -242,13 +237,15 @@ for the caveat. ## Worker-side consumption ```ts +import { getWorkspace } from "@cloudflare/workspace"; + export default { async fetch(request: Request, env: Env): Promise { const id = env.ContainerExample.idFromName("user-123"); - using ws = await env.ContainerExample.get(id).getWorkspace(); + using ws = await getWorkspace(env.ContainerExample.get(id)); await ws.fs.writeFile("/notes.md", "hello"); - using handle = await ws.shell.exec("ls /workspace"); + using handle = await ws.shell.exec("ls /workspace", { encoding: "utf8" }); const { exitCode, stdout } = await handle.result(); return new Response(stdout, { status: exitCode === 0 ? 200 : 500 }); @@ -256,6 +253,62 @@ export default { } satisfies ExportedHandler; ``` +`getWorkspace(stub)` calls the accessor the `withWorkspace` mixin +installed on the durable object, then wraps the returned stub in a +Worker-side client. Called with the durable object itself +(`getWorkspace(this)`), it returns the same client backed by the +in-isolate Workspace, so the surface is identical in both places. The +client mirrors the stub surface (`fs`, `git`, `shell`, `artifacts`, +`assets`); the only difference is that +`shell.exec` also accepts a tagged template, covered next. + +### Building commands safely + +`shell.exec` runs one command string through `/bin/sh -c` in the +container. Pasting a path or any other value straight into that string +is a shell-injection risk: a value like `x; rm -rf /` breaks out of its +argument. Call `exec` as a tagged template and interpolated values are +escaped for you: + +```ts +const file = "my notes.md"; +const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md' +``` + +The tagged-template form defaults to string (`utf8`) output, since a +caller reaching for it almost always wants text back. + +The plain `exec(command, options)` form is unchanged. Use it when you +need `cwd` or `backend`, and wrap an interpolated command in the `sh` +tag to escape it: + +```ts +import { sh } from "@cloudflare/workspace"; + +await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" }); +await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" }); +``` + +The plain form defaults to `Uint8Array` output; pass +`{ encoding: "utf8" }` for a string. + +`sh` quotes strings and numbers, quotes arrays element-by-element, and +leaves the static template parts alone — they're the trusted command. +When you really do mean shell syntax, wrap the value in `{ raw: "..." }` +to opt out of escaping for that one value: + +```ts +await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`); +``` + +The escaping has to run in the caller, not on the durable-object side: +when the command crosses Workers RPC, a tagged template's `.raw` +property doesn't survive structured clone, so the wrapper (and `sh`) +collapse the template to a finished string before the call. The remote +stub's `exec` rejects a raw tagged-template call so the unescaped path +fails loudly. `shellQuote` is exported too, for the rare case where +you need to quote a single argument outside a template. + ## Observability The package emits one span per documented operation through an optional From dad9549625f4e6ae41a06ffd068e33b32cf1f3b4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:12:24 +0100 Subject: [PATCH 6/8] workspace: Route service proxy through stub accessor --- packages/workspace/src/proxy.ts | 16 +++--- .../workspace/tests/worker-backend-worker.ts | 55 ++++++++----------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/packages/workspace/src/proxy.ts b/packages/workspace/src/proxy.ts index b9af1a60..6d016dac 100644 --- a/packages/workspace/src/proxy.ts +++ b/packages/workspace/src/proxy.ts @@ -103,8 +103,8 @@ export class ArtifactsCLITarget extends RpcTarget { } // WorkspaceServiceProxy — a loopback WorkerEntrypoint that -// exposes the host DO's getWorkspace() method as a callable -// Fetcher binding. Wired into a Dynamic Worker's env by a +// exposes the host DO's Workspace stub as a callable Fetcher +// binding. Wired into a Dynamic Worker's env by a // Worker Loader callback so the loaded Worker can reach the // host Workspace without being handed the DO namespace // directly. @@ -136,7 +136,7 @@ export class ArtifactsCLITarget extends RpcTarget { // The proxy resolves env[binding] at call time — the same lazy // lookup WorkspaceProxy does for the /ws upgrade path — so the // DO class doesn't need to live in @cloudflare/workspace. Any -// DO that exposes a `getWorkspace(): WorkspaceStub` RPC method +// DO that exposes a `__getWorkspaceStub(): WorkspaceStub` RPC method // works. export interface WorkspaceServiceProxyProps { // Name of a DurableObjectNamespace binding in env. The proxy @@ -148,12 +148,12 @@ export interface WorkspaceServiceProxyProps { } export class WorkspaceServiceProxy extends WorkerEntrypoint { - // Forward to the host DO's getWorkspace() method. The DO class - // is expected to expose this method; both the container - // and worker example DOs do. + // Forward to the host DO's Workspace stub accessor. The method + // name is intentionally private-looking so user code reaches for + // `getWorkspace(stub)` instead of calling it directly. async getWorkspace(): Promise { - const stub = this.#hostStub<{ getWorkspace(): Promise }>(); - return stub.getWorkspace(); + const stub = this.#hostStub<{ __getWorkspaceStub(): Promise }>(); + return stub.__getWorkspaceStub(); } // Optional Artifacts CLI hook used by the worker-backend shell. diff --git a/packages/workspace/tests/worker-backend-worker.ts b/packages/workspace/tests/worker-backend-worker.ts index 0515ea17..4d964df2 100644 --- a/packages/workspace/tests/worker-backend-worker.ts +++ b/packages/workspace/tests/worker-backend-worker.ts @@ -19,8 +19,7 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { WorkerBackend } from "../src/backends/worker/index.js"; -import type { DurableObjectStorageLike, WorkspaceStub } from "../src/index.js"; -import { Workspace } from "../src/index.js"; +import { type DurableObjectStorageLike, getWorkspace, withWorkspace } from "../src/index.js"; export { WorkspaceServiceProxy } from "../src/proxy.js"; @@ -29,24 +28,21 @@ export interface Env { LOADER: WorkerLoader; } -export class HostDO extends DurableObject { - readonly #workspace: Workspace; +export class HostDO extends withWorkspace(class extends DurableObject {}, (self) => { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [ + new WorkerBackend({ + loader: env.LOADER, + workspace: { binding: "HOST", id: ctx.id.toString() }, + ctx, + }), + ], + }; +}) { #seeded: Promise | undefined; - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - this.#workspace = new Workspace({ - storage: ctx.storage as unknown as DurableObjectStorageLike, - backends: [ - new WorkerBackend({ - loader: env.LOADER, - workspace: { binding: "HOST", id: ctx.id.toString() }, - ctx, - }), - ], - }); - } - // The VFS is empty on a fresh DO — not even /workspace exists. // The wsd-container example seeds the mount root through wsd's // boot path; the worker example happens to seed it through an @@ -54,32 +50,29 @@ export class HostDO extends DurableObject { // /workspace directly. #seed(): Promise { if (this.#seeded === undefined) { - this.#seeded = this.#workspace.fs.mkdir("/workspace", { recursive: true }); + this.#seeded = (async () => { + using ws = await getWorkspace(this); + await ws.fs.mkdir("/workspace", { recursive: true }); + })(); } return this.#seeded; } - // Required by WorkspaceServiceProxy: the loopback proxy looks - // the host DO up by name and calls getWorkspace() to obtain the - // stub it returns to the Dynamic Worker. The shell's per-exec - // env.HOST.getWorkspace() call lands here. - async getWorkspace(): Promise { - await this.#workspace.ready(); - return this.#workspace.stub(); - } - async writeFile(path: string, body: string): Promise { await this.#seed(); - await this.#workspace.fs.writeFile(path, body); + using ws = await getWorkspace(this); + await ws.fs.writeFile(path, body); } async readFile(path: string): Promise { - return this.#workspace.fs.readFile(path, "utf8"); + using ws = await getWorkspace(this); + return ws.fs.readFile(path, "utf8"); } async exec(command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { await this.#seed(); - const handle = await this.#workspace.shell.exec(command, { + using ws = await getWorkspace(this); + const handle = await ws.shell.exec(command, { encoding: "utf8", }); const result = await handle.result(); From a2b4278cfccd25c3adc628372174336be48852f4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:13:19 +0100 Subject: [PATCH 7/8] workspace: Dispose rebuilt exec handles --- packages/workspace/src/client.test.ts | 18 +++++++++++++++++- packages/workspace/src/client.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/workspace/src/client.test.ts b/packages/workspace/src/client.test.ts index 74dd1627..dffae8c5 100644 --- a/packages/workspace/src/client.test.ts +++ b/packages/workspace/src/client.test.ts @@ -25,8 +25,10 @@ interface ExecCall { function fakeShell(): { shell: { exec: (c: string, o?: Record) => Promise }; calls: ExecCall[]; + disposedHandles: () => number; } { const calls: ExecCall[] = []; + let disposedHandles = 0; // A minimal handle satisfying both the local identity rehydrate // (which passes it through) and the remote rebuild (which calls // stream()/result()/kill()). stream() emits one JSONL exit frame so @@ -45,9 +47,13 @@ function fakeShell(): { }, }), kill: () => Promise.resolve(), + [Symbol.dispose]() { + disposedHandles += 1; + }, }); return { calls, + disposedHandles: () => disposedHandles, shell: { exec(command: string, options?: Record) { calls.push({ command, options }); @@ -63,8 +69,9 @@ function fakeRemote(): { host: WorkspaceStubHost; calls: ExecCall[]; disposed: () => boolean; + disposedHandles: () => number; } { - const { shell, calls } = fakeShell(); + const { shell, calls, disposedHandles } = fakeShell(); let disposed = false; const stub = { fs: { marker: "fs" }, @@ -79,6 +86,7 @@ function fakeRemote(): { return { calls, disposed: () => disposed, + disposedHandles, host: { __getWorkspaceStub: () => Promise.resolve(stub as never) }, }; } @@ -205,4 +213,12 @@ describe("client shell.exec — remote handle rebuild", () => { reader.releaseLock(); expect(() => (handle as { result(): unknown }).result()).toThrow(/already streaming/); }); + + it("disposes the remote handle when the rebuilt handle is disposed", async () => { + const { host, disposedHandles } = fakeRemote(); + const ws = await getWorkspace(host); + const handle = await ws.shell.exec("echo hi"); + handle[Symbol.dispose]?.(); + expect(disposedHandles()).toBe(1); + }); }); diff --git a/packages/workspace/src/client.ts b/packages/workspace/src/client.ts index e73552d3..03f9d29f 100644 --- a/packages/workspace/src/client.ts +++ b/packages/workspace/src/client.ts @@ -87,9 +87,17 @@ function rebuildExecHandle(remote: RemoteExecHandle): un // result()-only caller never trips the "already streaming" guard. { highWaterMark: 0 }, ); + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + void reader?.cancel(); + remote[Symbol.dispose]?.(); + }; const handle = stream as ReadableStream> & { result(): Promise; kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise; + [Symbol.dispose](): void; }; Object.defineProperties(handle, { result: { @@ -107,6 +115,10 @@ function rebuildExecHandle(remote: RemoteExecHandle): un value: (signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP") => remote.kill(signal), enumerable: false, }, + [Symbol.dispose]: { + value: dispose, + enumerable: false, + }, }); return handle; } From 33ffd43d79d67f47e99476943807c36e930ab2d4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:15:48 +0100 Subject: [PATCH 8/8] workspace: Pull exec wire events lazily --- packages/workspace/src/exec-wire.ts | 9 ++++-- packages/workspace/src/stub.test.ts | 47 +++++++++++++++++++++++++++ packages/workspace/src/stub.ts | 49 +++++++++++++++++------------ 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/packages/workspace/src/exec-wire.ts b/packages/workspace/src/exec-wire.ts index 8c6e537c..0f300004 100644 --- a/packages/workspace/src/exec-wire.ts +++ b/packages/workspace/src/exec-wire.ts @@ -80,15 +80,20 @@ function eventOf(frame: ExecFrame): WorkspaceExecEvent { } as WorkspaceExecEvent; } +const encoder = new TextEncoder(); + +export function encodeExecEvent(event: WorkspaceExecEvent): Uint8Array { + return encoder.encode(`${JSON.stringify(frameOf(event))}\n`); +} + // Frame a stream of exec events as JSONL bytes for the wire. export function encodeExecEvents( events: ReadableStream>, ): ReadableStream { - const encoder = new TextEncoder(); return events.pipeThrough( new TransformStream, Uint8Array>({ transform(event, controller) { - controller.enqueue(encoder.encode(`${JSON.stringify(frameOf(event))}\n`)); + controller.enqueue(encodeExecEvent(event)); }, }), ); diff --git a/packages/workspace/src/stub.test.ts b/packages/workspace/src/stub.test.ts index 3245c00e..e0af3eb7 100644 --- a/packages/workspace/src/stub.test.ts +++ b/packages/workspace/src/stub.test.ts @@ -422,6 +422,53 @@ describe("WorkspaceStub", () => { ); }); + it("shell.exec handle.stream() waits for the wire consumer to pull", async () => { + let pulls = 0; + const shellRpc: import("@cloudflare/workspace-rpc").ShellRPC = { + async exec() { + return { + id: "e-1", + events: new ReadableStream({ + pull(c) { + pulls += 1; + if (pulls < 10) { + c.enqueue({ + id: "e-1", + seq: pulls, + name: "stdout", + value: new Uint8Array([pulls]), + }); + return; + } + c.enqueue({ id: "e-1", seq: 10, name: "exit", value: 0 }); + c.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: () => Promise.reject(new Error("not used")), + }; + await withStub( + async (ws) => { + const stub = ws.stub(); + const handle = await stub.shell.exec("noop"); + const wire = handle.stream(); + await Promise.resolve(); + expect(pulls).toBe(0); + + const reader = wire.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toContain('"name":"stdout"'); + expect(pulls).toBeLessThan(10); + await reader.cancel(); + reader.releaseLock(); + }, + { backend: backend({ shell: shellRpc }) }, + ); + }); + it("shell.exec handle rejects result() after stream() has claimed it", async () => { const shellRpc: import("@cloudflare/workspace-rpc").ShellRPC = { async exec() { diff --git a/packages/workspace/src/stub.ts b/packages/workspace/src/stub.ts index 1c559e0e..d79fde97 100644 --- a/packages/workspace/src/stub.ts +++ b/packages/workspace/src/stub.ts @@ -67,7 +67,7 @@ import type { ArtifactsCLIResult, } from "./artifacts/index.js"; import type { ShareOptions } from "./assets/index.js"; -import { encodeExecEvents } from "./exec-wire.js"; +import { encodeExecEvent } from "./exec-wire.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; import { assertNotTemplate } from "./sh.js"; @@ -364,31 +364,40 @@ export class WorkspaceExecHandleStub e stream(): ReadableStream { this.#claim(); const consumer = this.#consumer; + let reader: ReadableStreamDefaultReader> | undefined; let exitCode = -1; - const source = new ReadableStream>({ - start: async (controller) => { - try { - const handle = await this.#handle; - const reader = (handle as ReadableStream>).getReader(); + return new ReadableStream( + { + pull: async (controller) => { try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value.name === "exit") exitCode = value.value; - controller.enqueue(value); + if (reader === undefined) { + const handle = await this.#handle; + reader = (handle as ReadableStream>).getReader(); + } + const { value, done } = await reader.read(); + if (done) { + reader.releaseLock(); + reader = undefined; + controller.close(); + consumer.resolve({ exitCode, pushed: 0, pulled: 0, skippedCount: 0 }); + return; } - } finally { - reader.releaseLock(); + if (value.name === "exit") exitCode = value.value; + controller.enqueue(encodeExecEvent(value as WorkspaceExecEvent)); + } catch (error) { + consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); + controller.error(error); } - controller.close(); - consumer.resolve({ exitCode, pushed: 0, pulled: 0, skippedCount: 0 }); - } catch (error) { + }, + cancel: async (reason) => { consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); - controller.error(error); - } + await reader?.cancel(reason); + reader?.releaseLock(); + reader = undefined; + }, }, - }); - return encodeExecEvents(source as ReadableStream>); + { highWaterMark: 0 }, + ); } async kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise {