Skip to content

Re-design exec interface - #13

Merged
aron-cf merged 8 commits into
mainfrom
exec
Jul 29, 2026
Merged

Re-design exec interface#13
aron-cf merged 8 commits into
mainfrom
exec

Conversation

@aron-cf

@aron-cf aron-cf commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Reaching a Workspace from a Worker used to go through a reduced stub. It could run a command and wait for the result, but it could not stream output and it did nothing to protect against shell injection. Callers built commands by pasting values into a string, so a name like x; rm -rf / broke out of its argument, and every example grew its own quoting helper to work around it. The durable object that owned the Workspace had the full surface, so the two sides had drifted apart.

This change gives both sides one interface. getWorkspace is a single front door that returns the same client whether you hand it the durable object itself or the object's stub from a Worker.

// From a Worker, against the durable object stub.
using ws = await getWorkspace(env.MyDO.get(id));

// From inside the durable object that owns the Workspace.
using ws = await getWorkspace(this);

The durable object opts in by extending a mixin. It constructs the Workspace and installs the accessor getWorkspace needs, so there is no hand-written method to maintain.

export class MyDO extends withWorkspace(
  class extends DurableObject<Env> {},
  (self) => ({ storage: self.ctx.storage, backends: [/* ... */] }),
) {}

Commands are now escaped by default. shell.exec takes a tagged template that quotes every interpolated value, so a hostile input stays inside its argument.

const file = "my notes.md";
await ws.shell.exec`cat ${file}`;        // runs: cat 'my notes.md'
await ws.shell.exec`echo ${untrusted}`;  // the value can't break out

The plain form still exists for when you need options, and the sh tag is exported so you can escape a command you pass to it.

await ws.shell.exec("npm test", { cwd: "/workspace", encoding: "utf8" });
await ws.shell.exec(sh`cat ${file}`, { cwd: "/workspace" });

The escaping runs on the caller's side on purpose. When a command crosses the boundary to the durable object it travels as a plain value, and a tagged template's raw text is lost in transit, so escaping there would be too late. Building the finished string in the caller is the only place it can happen safely, and the remote stub rejects a raw tagged-template call so the unescaped path can never run by accident.

The same client can now stream a command's output from a Worker, not just wait for its result. Workers RPC carries byte streams but not arbitrary object streams, so the event stream is framed as newline-delimited JSON on the durable-object side and rebuilt into the familiar handle on the Worker side.

for await (const event of await ws.shell.exec`npm test`) {
  if (event.name === "stdout") process.stdout.write(event.value);
}

// Or wait for the whole run.
const { exitCode, stdout } = await (await ws.shell.exec`npm test`).result();

Streaming and waiting for the result are mutually exclusive on a single handle, matching how the in-process handle already behaves. The stream is only started when you actually read from it, so a caller that only wants the result never pays for it.

flowchart LR
  W["Worker: getWorkspace(stub)"] -->|"exec command"| DO["Durable Object"]
  DO -->|"JSON lines over byte stream"| W
  W -->|"rebuilt into events"| C["for await (event of handle)"]
Loading

To verify locally, run the workspace tests, which cover the escaping, the wire codec round trip including binary and multi-byte output, and both the local and remote client paths:

npm test --workspace @cloudflare/workspace

The artifacts example is updated to the new surface: it extends the mixin, reaches the workspace through getWorkspace, and builds its commands with the sh tag instead of a local quoting helper. The shell interface guide and the package README are updated to describe getWorkspace, the tagged template, and why escaping runs caller-side.

The client's passthrough members for the filesystem, git, assets, and artifacts are typed loosely today because the local and remote surfaces are different concrete types. Unifying them behind one declared interface is a good follow-up now that the shape has settled. The new streaming path has unit and integration coverage but has not been run through the long-lived stub-disposal soak harness, which needs a real container; that is worth running before release.

@ndisidore

Copy link
Copy Markdown
Member

Wow I like this a lot more! And very nice to see interface unification efforts.
Happy to give this a review once you're satisfied with to move it out of draft

aron-cf added 3 commits July 29, 2026 12:54
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.
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.
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.
@aron-cf
aron-cf marked this pull request as ready for review July 29, 2026 11:54
aron-cf added 2 commits July 29, 2026 13:05
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.
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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/workspace/@cloudflare/workspace@13

commit: 33ffd43

@aron-cf
aron-cf merged commit e9e01d8 into main Jul 29, 2026
11 checks passed
@aron-cf
aron-cf deleted the exec branch July 29, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants