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:
@@ -56,17 +56,6 @@ export interface IWorkspaces {
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
openPath(path: string): Promise<void>
|
||||
/**
|
||||
* URL serving one file out of a session's workspace, for a UI that opens a
|
||||
* produced file in the browser instead of on the Host machine.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory, or `undefined` when unknown.
|
||||
* @param path - the path a tool reported (absolute, or relative to `cwd`).
|
||||
* @returns the origin-relative URL, or `undefined` when the path lies
|
||||
* outside the workspace — which this transport never serves, leaving
|
||||
* {@link IWorkspaces.openPath} as the only way to reach it.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
|
||||
@@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL serving one file out of a session's workspace.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory, or `undefined` when unknown.
|
||||
* @param path - the path a tool reported (absolute, or relative to `cwd`).
|
||||
* @returns the origin-relative URL, or `undefined` for a path outside the workspace.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
if (segments === undefined) return undefined
|
||||
return workspaceFileUrl(sessionId, segments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
|
||||
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
fileUrl: () => undefined,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
|
||||
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
fileUrl: () => undefined,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -276,21 +276,6 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('addresses a workspace file by URL, and only inside the workspace', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
const session = 's-1' as SessionId
|
||||
// The URL is derived, not fetched: no wire call answers a link.
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
|
||||
// Outside the workspace there is nothing this transport may serve, which
|
||||
// is the signal a caller falls back to openPath on.
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
|
||||
expect(api.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
Reference in New Issue
Block a user