cleanup(web): stop sandboxing served workspace documents
A preview lost localStorage and cookies under CSP sandbox — measurably, the reported artifact throws SecurityError on load and its theme toggle goes dead. The capability the sandbox denied is one the file's author, an agent already holding this user's shell, never needed the browser for, so the header sat behind a trust boundary it had already crossed. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header.
This commit is contained in:
@@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread
|
||||
|
||||
**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy.
|
||||
|
||||
**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either.
|
||||
**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header.
|
||||
|
||||
**The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed.
|
||||
|
||||
@@ -30,9 +30,9 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread
|
||||
- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule.
|
||||
- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route.
|
||||
- **`/f/<absolute path>`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope.
|
||||
- **`connect-src 'none'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional.
|
||||
- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes.
|
||||
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown.
|
||||
Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown.
|
||||
|
||||
Reference in New Issue
Block a user