feat(web): serve workspace files from their own origin

A sandbox header bought isolation by taking the document's origin away, and
measuring that cost decided against it: the reported artifact throws
SecurityError on load, and because an uncaught exception aborts the rest of
its <script>, every listener declared after that line — theme toggle, mobile
menu, model tabs — never binds. Two of the four artifacts in the reporting
user's workspace were dead pages under it, and they still looked right.

A second listener on the API's host, answering /f and nothing else, is the
same boundary without the amputation: cross-origin to /api (refused by the
Origin fence and by CORS), same-origin with itself (localStorage, cookies and
fetch all work). Its port is published into the index page; the browser half
reads it to address previews, and its absence — the keyless fixture lane — is
what makes a file row fall back to the Host opener instead of a dead tab.

fileUrl moves from IWorkspaces to ConnectionHandle: the transport owns both
the listener that serves the bytes and the port that addresses it.
This commit is contained in:
ZiyaZhang
2026-08-01 02:17:25 -07:00
parent 1082518520
commit 59bfe77fb8
37 changed files with 469 additions and 197 deletions

View File

@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
@@ -275,11 +276,12 @@ export function apply(ctx: Context): void {
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
// A file inside the workspace opens in a new tab, so a browser that
// is not on the Host machine can still see what the agent produced.
// Anything outside it has no served URL and falls back to the Host's
// own opener, which is loopback-only by the /api trust fence.
const url = workspaces.fileUrl(sessionId, cwd, path)
// A file inside the workspace opens in a new tab on the transport's
// workspace-file origin, so a browser that is not on the Host machine
// can still see what the agent produced. Anything outside it has no
// served URL and falls back to the Host's own opener, which is
// loopback-only by the /api trust fence.
const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path)
if (url !== undefined) {
window.open(url, '_blank', 'noopener,noreferrer')
return