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:
@@ -25,6 +25,7 @@
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
@@ -35,6 +36,7 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
|
||||
48
packages/client/test-runtime/src/connection.ts
Normal file
48
packages/client/test-runtime/src/connection.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Test-owned connection face: the transport members features read off `ctx.connection`. */
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/**
|
||||
* Connection test double. Implements the same `ConnectionHandle` face features
|
||||
* receive as `ctx.connection`, so a production face change breaks this double
|
||||
* at compile time. The wire client is not modelled — a feature that needs one
|
||||
* composes its own connection over a fake api client; this double exists for
|
||||
* the transport facts features read synchronously, above all the
|
||||
* workspace-file URL.
|
||||
*/
|
||||
export class TestConnection implements ConnectionHandle {
|
||||
/**
|
||||
* The workspace-file port the host would have published into the page.
|
||||
* Unset — the default, and the keyless fixture lane's real state — makes
|
||||
* {@link TestConnection.fileUrl} answer `undefined`, which is the signal a
|
||||
* caller falls back to the Host opener on.
|
||||
*/
|
||||
filesPort: number | undefined
|
||||
|
||||
/** The wire client; unused by this double's consumers and absent by construction. */
|
||||
readonly api: IApiClient = undefined as unknown as IApiClient
|
||||
|
||||
/**
|
||||
* Stream-loop starter (inert).
|
||||
* @returns a stop handle that does nothing.
|
||||
*/
|
||||
start(): { stop(): void } {
|
||||
return { stop: () => {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-file URL, deriving exactly as production does so a feature test
|
||||
* sees the real inside/outside-workspace split.
|
||||
* @param sessionId - the Session whose cwd anchors the path.
|
||||
* @param cwd - that Session's working directory.
|
||||
* @param path - the path a tool reported.
|
||||
* @returns the absolute URL on the workspace-file origin, or undefined when
|
||||
* the path leaves the workspace or no port is published.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
|
||||
if (this.filesPort === undefined) return undefined
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
if (segments === undefined) return undefined
|
||||
return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}`
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
import { TestSessions } from './sessions.ts'
|
||||
import { TestConnection } from './connection.ts'
|
||||
import { TestWorkspaces } from './workspaces.ts'
|
||||
import type { Stabilizer } from './fixtures.ts'
|
||||
|
||||
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
|
||||
export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { TestConnection } from './connection.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
@@ -175,6 +177,8 @@ export class SlotTestRuntime {
|
||||
readonly sessions: TestSessions
|
||||
/** Workspaces double (list observable, recorded intent actions). */
|
||||
readonly workspaces: TestWorkspaces
|
||||
/** The transport double features read as `ctx.connection`. */
|
||||
readonly connection: TestConnection
|
||||
|
||||
private readonly stabilizer: Stabilizer = async (fn) => {
|
||||
await act(async () => { await fn() })
|
||||
@@ -195,8 +199,10 @@ export class SlotTestRuntime {
|
||||
this.root = new TestRoot(slots, this.stabilizer)
|
||||
this.sessions = new TestSessions(this.stabilizer, ctx)
|
||||
this.workspaces = new TestWorkspaces(this.stabilizer)
|
||||
this.connection = new TestConnection()
|
||||
ctx.provide('sessions', this.sessions)
|
||||
ctx.provide('workspaces', this.workspaces)
|
||||
ctx.provide('connection', this.connection)
|
||||
// Capturing install: the production renderer does the rendering; the
|
||||
// wrapper only takes the host face for storeOf (no machinery copied).
|
||||
const renderer = createSlotRenderer()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-file URL (recorded). Runs the production path derivation so a
|
||||
* feature test sees the real in/outside-workspace split; stub to force either.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory.
|
||||
* @param path - the path a tool reported.
|
||||
* @returns the origin-relative URL, or undefined outside the workspace.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
|
||||
this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] })
|
||||
const stub = this.stubs.get('fileUrl')
|
||||
if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory picker (recorded). The default cancels (null); stub to select.
|
||||
|
||||
@@ -549,10 +549,6 @@ describe('workspaces action face', () => {
|
||||
expect(renamed.title).toBe('Renamed')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/proj/file.ts')
|
||||
// fileUrl runs the production derivation, so a feature test sees the same
|
||||
// inside/outside-workspace split the browser half decides on.
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html')
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined()
|
||||
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
|
||||
expect(moved.sessionIds).toEqual(['s1'])
|
||||
// Default archive mirrors the production effect: the id joins the list
|
||||
@@ -560,15 +556,13 @@ describe('workspaces action face', () => {
|
||||
await ws.archiveSession('s1' as SessionId)
|
||||
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
|
||||
expect(ws.calls.map(c => c.method)).toEqual(
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl',
|
||||
'insertSessionBefore', 'archiveSession'])
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
|
||||
|
||||
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
|
||||
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
|
||||
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
|
||||
ws.stub('delete', () => Promise.resolve())
|
||||
ws.stub('openPath', () => Promise.resolve())
|
||||
ws.stub('fileUrl', () => '/f/forced/a.html')
|
||||
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
|
||||
ws.stub('archiveSession', () => Promise.resolve())
|
||||
expect((await ws.create({ name: 'y' })).title).toBe('X')
|
||||
@@ -576,7 +570,6 @@ describe('workspaces action face', () => {
|
||||
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/other')
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html')
|
||||
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
|
||||
// The stub replaces the default set mutation: the set stays as-is.
|
||||
await ws.archiveSession('s2' as SessionId)
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user