Conversation
Member
|
Wow I like this a lot more! And very nice to see interface unification efforts. |
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
marked this pull request as ready for review
July 29, 2026 11:54
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.
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reaching a
Workspacefrom 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 likex; rm -rf /broke out of its argument, and every example grew its own quoting helper to work around it. The durable object that owned theWorkspacehad the full surface, so the two sides had drifted apart.This change gives both sides one interface.
getWorkspaceis 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.The durable object opts in by extending a mixin. It constructs the
Workspaceand installs the accessorgetWorkspaceneeds, so there is no hand-written method to maintain.Commands are now escaped by default.
shell.exectakes a tagged template that quotes every interpolated value, so a hostile input stays inside its argument.The plain form still exists for when you need options, and the
shtag is exported so you can escape a command you pass to it.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.
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.
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/workspaceThe
artifactsexample is updated to the new surface: it extends the mixin, reaches the workspace throughgetWorkspace, and builds its commands with theshtag instead of a local quoting helper. The shell interface guide and the package README are updated to describegetWorkspace, 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.