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/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, `'"'"'`)}'`; -} 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 diff --git a/packages/workspace/src/client.test.ts b/packages/workspace/src/client.test.ts new file mode 100644 index 00000000..dffae8c5 --- /dev/null +++ b/packages/workspace/src/client.test.ts @@ -0,0 +1,224 @@ +// 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[]; + 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 + // 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(), + [Symbol.dispose]() { + disposedHandles += 1; + }, + }); + return { + calls, + disposedHandles: () => disposedHandles, + 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; + disposedHandles: () => number; +} { + const { shell, calls, disposedHandles } = 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, + disposedHandles, + 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/); + }); + + 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 new file mode 100644 index 00000000..03f9d29f --- /dev/null +++ b/packages/workspace/src/client.ts @@ -0,0 +1,279 @@ +// 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 }, + ); + 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: { + 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, + }, + [Symbol.dispose]: { + value: dispose, + 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/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..0f300004 --- /dev/null +++ b/packages/workspace/src/exec-wire.ts @@ -0,0 +1,137 @@ +// 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; +} + +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 { + return events.pipeThrough( + new TransformStream, Uint8Array>({ + transform(event, controller) { + controller.enqueue(encodeExecEvent(event)); + }, + }), + ); +} + +// 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 = ""; + }, + }), + ); +} 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/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/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. diff --git a/packages/workspace/src/stub.test.ts b/packages/workspace/src/stub.test.ts index bdc4a0f1..e0af3eb7 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,123 @@ 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.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() { + 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..d79fde97 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 { encodeExecEvent } 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,159 @@ 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 reader: ReadableStreamDefaultReader> | undefined; + let exitCode = -1; + return new ReadableStream( + { + pull: async (controller) => { + try { + 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; + } + 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); + } + }, + cancel: async (reason) => { + consumer.resolve({ exitCode: -1, pushed: 0, pulled: 0, skippedCount: 0 }); + await reader?.cancel(reason); + reader?.releaseLock(); + reader = undefined; + }, + }, + { highWaterMark: 0 }, + ); + } + + async kill(signal?: "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"): Promise { + const handle = await this.#handle; + await handle.kill(signal); } } @@ -438,6 +560,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 +578,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 +608,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; +} 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();