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

@@ -15,6 +15,9 @@ import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
// The merge-free types subpath: api/ is imported from the browser lane, where
// the host session service must not merge over the client runtime's own.
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
@@ -30,6 +33,17 @@ export interface ApiProxy {
llm: LlmApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
/**
* The directory a Session's files may be read from — the same `cwd` the
* session summaries carry, in non-envelope form for an in-process reader.
* Not a domain method: it has no wire face, because a browser learns a
* Session's cwd from `sessions.view` and a file it may read from the web
* transport's own `/f` route, never by asking for a host path.
* @param sessionId - the Session to locate.
* @returns its absolute working directory, or `undefined` when this host
* serves no such Session. Resolving one never resumes an agent.
*/
workspaceRootOf(sessionId: SessionId): Promise<string | undefined>
}
// ---- Domain interfaces and payload entities ----
@@ -48,6 +62,10 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSe
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
// ---- Workspace-file URL shape (the transport's byte-carrying half) ----
export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts'
export type { WorkspaceFileTarget } from './files.ts'
export type { QuestionResponsePayload } from './questions.ts'
// ---- Message layer: narrow forms (domain-signature view) ----