feat(web): open a produced file from the conversation

Serve one file at a time out of a Session's workspace under /f on the web
transport, and point the conversation's existing file-open affordance at it.
Clicking a write/edit/read row's path now opens that file in a browser tab —
including from a LAN client, where the Host's system opener is fenced to
loopback and answered nothing.

- /f/<sessionId>/<segments> in client-connection, behind the same
  browser-trust fence as /api; realpath confinement, streamed reads,
  GET/HEAD only, nosniff + no-store.
- Script-capable documents carry CSP sandbox: model-authored markup must not
  be same-origin with /api, where events.mux is a readable GET stream.
- ApiProxy.workspaceRootOf answers where a Session's files live without
  resuming an agent; the client program cannot reach the core services.
- The /f URL shape lives in dsh-host-apiproxy/api so both ends share one
  encoding (client bundles may not value-import another plugin).
This commit is contained in:
ZiyaZhang
2026-07-31 12:07:43 -07:00
parent 992fdc0cee
commit 00390ae851
35 changed files with 946 additions and 30 deletions

View File

@@ -56,6 +56,17 @@ 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.

View File

@@ -5,6 +5,7 @@ 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'
@@ -239,6 +240,19 @@ 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.
* @param workspaceId - target workspace.