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:
@@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
|
||||
const b = await bench()
|
||||
// A host that publishes a workspace-file port: previews come from that
|
||||
// origin, which is what keeps them off the API's.
|
||||
b.runtime.connection.filesPort = 4321
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
// Inside the session cwd: served by this origin, so a browser anywhere on
|
||||
// the network sees the file the agent produced.
|
||||
// Inside the session cwd: served on the workspace-file origin, so a browser
|
||||
// anywhere on the network sees the file the agent produced.
|
||||
injected.openFile('src/a.ts')
|
||||
expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
|
||||
expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
|
||||
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
|
||||
// Outside it there is no served URL, so the Host's own opener answers —
|
||||
// resolved against the session cwd exactly as before.
|
||||
|
||||
@@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
// The transport face the chat view reads its workspace-file URLs from.
|
||||
const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) }
|
||||
ctx.provide('connection', connection)
|
||||
ctx.provide('layout', layout)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
@@ -249,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
view.getByText('notes/demo.txt').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
|
||||
expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
open.mockRestore()
|
||||
view.getByText('List notes').click()
|
||||
|
||||
@@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
b.runtime.connection.filesPort = 4321
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('src/a.ts').click()
|
||||
|
||||
Reference in New Issue
Block a user