Skip to content

Commit fcb7589

Browse files
committed
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.
1 parent 2977a5b commit fcb7589

2 files changed

Lines changed: 157 additions & 31 deletions

File tree

docs/05_shell_interface.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,79 @@ await run.kill(); // SIGTERM
183183
await run.kill("SIGKILL");
184184
```
185185

186+
## Building commands safely
187+
188+
`exec` takes one command string, and the container runs it through
189+
`/bin/sh -c`. Interpolating a value straight into that string is a
190+
shell-injection risk: a path or branch name like `x; rm -rf /` breaks
191+
out of its argument.
192+
193+
Reach the Workspace through `getWorkspace` and call `shell.exec` as a
194+
tagged template. Interpolated values are escaped before the command
195+
runs. `getWorkspace` takes either the durable object stub from a
196+
Worker (`getWorkspace(env.MyDO.get(id))`) or the durable object itself
197+
from inside it (`getWorkspace(this)`); the surface is identical both
198+
ways:
199+
200+
```ts
201+
import { getWorkspace } from "@cloudflare/workspace";
202+
203+
using ws = await getWorkspace(env.MyDO.get(id));
204+
205+
const file = "my notes.md";
206+
const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md'
207+
```
208+
209+
Strings and numbers are quoted, arrays are quoted element-by-element
210+
and joined with spaces, and the literal parts of the template (the
211+
trusted command) are emitted verbatim. The tagged-template form
212+
defaults to string (`utf8`) output, since a caller reaching for it
213+
almost always wants text back.
214+
215+
The plain `exec(command, options)` form is unchanged and still
216+
available. Use it when you need `cwd` or `backend`, and wrap an
217+
interpolated command in the `sh` tag to escape it:
218+
219+
```ts
220+
import { sh } from "@cloudflare/workspace";
221+
222+
await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" });
223+
await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" });
224+
```
225+
226+
The plain form defaults to `Uint8Array` output; pass
227+
`{ encoding: "utf8" }` for a string.
228+
229+
`sh` is exported on its own for composing a command string — the
230+
building block both the tagged-template `exec` and the
231+
`exec(sh`...`, options)` form use. Its escaping rules: strings and
232+
numbers are quoted, arrays are quoted element-by-element, and the
233+
static parts come from `strings.raw` so a backslash you write in the
234+
template reaches the shell as written. To splice in deliberate shell
235+
syntax — a pipe, a redirect, a pre-quoted sub-command — wrap the value
236+
in `{ raw: "..." }` to opt out of escaping for that one value:
237+
238+
```ts
239+
await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`);
240+
```
241+
242+
The single-argument quoter `shellQuote` is exported for cases that
243+
don't fit a template.
244+
245+
### Why escaping runs caller-side
246+
247+
`sh` collapses a template to a finished string before the call
248+
because of the RPC boundary. When a Worker calls `exec` through the
249+
Workspace stub, the command crosses Workers RPC as a value and is run
250+
on the durable-object side. A `TemplateStringsArray` does not survive
251+
that trip intact — structured clone keeps the indexed string parts but
252+
drops the `.raw` property the escaping relies on. So the escaping has
253+
to happen in the caller, which is what `getWorkspace`'s client does
254+
for the tagged-template form and what `sh` does explicitly. The
255+
remote stub's `exec` rejects a raw tagged-template call with a
256+
`TypeError` rather than run an unescaped command, so the unsafe path
257+
fails loudly.
258+
186259
## Working directory
187260

188261
`cwd` is optional and defaults to the workspace root (see

packages/workspace/README.md

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -74,59 +74,54 @@ uniform; the counts are just always zero.
7474
Container backend:
7575

7676
```ts
77-
import { Workspace, WorkspaceProxy } from "@cloudflare/workspace";
77+
import { withWorkspace, WorkspaceProxy } from "@cloudflare/workspace";
7878
import { CloudflareContainerBackend, withWorkspaceContainer }
7979
from "@cloudflare/workspace/backends/container";
8080
import { DurableObject } from "cloudflare:workers";
8181

8282
export { WorkspaceProxy };
8383

84-
export class ContainerExample extends withWorkspaceContainer(class extends DurableObject<Env> {}) {
85-
#workspace = new Workspace({
86-
storage: this.ctx.storage,
84+
// `withWorkspace` constructs the Workspace and installs the plumbing
85+
// `getWorkspace` needs — no hand-written stub method. The options
86+
// callback runs after `super(...)`, so it can read `self.ctx`. Compose
87+
// it with `withWorkspaceContainer` when the durable object also owns
88+
// the container binding.
89+
export class ContainerExample extends withWorkspace(
90+
withWorkspaceContainer(class extends DurableObject<Env> {}),
91+
(self) => ({
92+
storage: self.ctx.storage,
8793
backends: [
8894
new CloudflareContainerBackend({
89-
container: () => this,
90-
workspace: { binding: "ContainerExample", id: this.ctx.id.toString() },
95+
container: () => self,
96+
workspace: { binding: "ContainerExample", id: self.ctx.id.toString() },
9197
}),
9298
],
93-
});
94-
95-
async getWorkspace(): Promise<WorkspaceStub> {
96-
await this.#workspace.ready();
97-
return this.#workspace.stub();
98-
}
99-
100-
override fetch(req: Request) { return this.#workspace; /* see example */ }
101-
}
99+
}),
100+
) {}
102101
```
103102

104103
Worker backend:
105104

106105
```ts
107-
import { Workspace, WorkspaceServiceProxy } from "@cloudflare/workspace";
106+
import { withWorkspace, WorkspaceServiceProxy } from "@cloudflare/workspace";
108107
import { WorkerBackend } from "@cloudflare/workspace/backends/worker";
109108
import { DurableObject } from "cloudflare:workers";
110109

111110
export { WorkspaceServiceProxy };
112111

113-
export class ContainerExample extends DurableObject<Env> {
114-
#workspace = new Workspace({
115-
storage: this.ctx.storage,
112+
export class ContainerExample extends withWorkspace(
113+
class extends DurableObject<Env> {},
114+
(self) => ({
115+
storage: self.ctx.storage,
116116
backends: [
117117
new WorkerBackend({
118-
loader: env.LOADER,
119-
workspace: { binding: "ContainerExample", id: this.ctx.id.toString() },
120-
ctx,
118+
loader: self.env.LOADER,
119+
workspace: { binding: "ContainerExample", id: self.ctx.id.toString() },
120+
ctx: self.ctx,
121121
}),
122122
],
123-
});
124-
125-
async getWorkspace(): Promise<WorkspaceStub> {
126-
await this.#workspace.ready();
127-
return this.#workspace.stub();
128-
}
129-
}
123+
}),
124+
) {}
130125
```
131126

132127
Filesystem only — no backend, no shell:
@@ -242,20 +237,78 @@ for the caveat.
242237
## Worker-side consumption
243238

244239
```ts
240+
import { getWorkspace } from "@cloudflare/workspace";
241+
245242
export default {
246243
async fetch(request: Request, env: Env): Promise<Response> {
247244
const id = env.ContainerExample.idFromName("user-123");
248-
using ws = await env.ContainerExample.get(id).getWorkspace();
245+
using ws = await getWorkspace(env.ContainerExample.get(id));
249246

250247
await ws.fs.writeFile("/notes.md", "hello");
251-
using handle = await ws.shell.exec("ls /workspace");
248+
using handle = await ws.shell.exec("ls /workspace", { encoding: "utf8" });
252249
const { exitCode, stdout } = await handle.result();
253250

254251
return new Response(stdout, { status: exitCode === 0 ? 200 : 500 });
255252
},
256253
} satisfies ExportedHandler<Env>;
257254
```
258255

256+
`getWorkspace(stub)` calls the accessor the `withWorkspace` mixin
257+
installed on the durable object, then wraps the returned stub in a
258+
Worker-side client. Called with the durable object itself
259+
(`getWorkspace(this)`), it returns the same client backed by the
260+
in-isolate Workspace, so the surface is identical in both places. The
261+
client mirrors the stub surface (`fs`, `git`, `shell`, `artifacts`,
262+
`assets`); the only difference is that
263+
`shell.exec` also accepts a tagged template, covered next.
264+
265+
### Building commands safely
266+
267+
`shell.exec` runs one command string through `/bin/sh -c` in the
268+
container. Pasting a path or any other value straight into that string
269+
is a shell-injection risk: a value like `x; rm -rf /` breaks out of its
270+
argument. Call `exec` as a tagged template and interpolated values are
271+
escaped for you:
272+
273+
```ts
274+
const file = "my notes.md";
275+
const out = await (await ws.shell.exec`cat ${file}`).result(); // cat 'my notes.md'
276+
```
277+
278+
The tagged-template form defaults to string (`utf8`) output, since a
279+
caller reaching for it almost always wants text back.
280+
281+
The plain `exec(command, options)` form is unchanged. Use it when you
282+
need `cwd` or `backend`, and wrap an interpolated command in the `sh`
283+
tag to escape it:
284+
285+
```ts
286+
import { sh } from "@cloudflare/workspace";
287+
288+
await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" });
289+
await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" });
290+
```
291+
292+
The plain form defaults to `Uint8Array` output; pass
293+
`{ encoding: "utf8" }` for a string.
294+
295+
`sh` quotes strings and numbers, quotes arrays element-by-element, and
296+
leaves the static template parts alone — they're the trusted command.
297+
When you really do mean shell syntax, wrap the value in `{ raw: "..." }`
298+
to opt out of escaping for that one value:
299+
300+
```ts
301+
await ws.shell.exec(sh`ls ${dir} ${{ raw: "| wc -l" }}`);
302+
```
303+
304+
The escaping has to run in the caller, not on the durable-object side:
305+
when the command crosses Workers RPC, a tagged template's `.raw`
306+
property doesn't survive structured clone, so the wrapper (and `sh`)
307+
collapse the template to a finished string before the call. The remote
308+
stub's `exec` rejects a raw tagged-template call so the unescaped path
309+
fails loudly. `shellQuote` is exported too, for the rare case where
310+
you need to quote a single argument outside a template.
311+
259312
## Observability
260313

261314
The package emits one span per documented operation through an optional

0 commit comments

Comments
 (0)