refactor(web): open produced files through the Host, not over HTTP

Scope decision: previews for a browser that is not on the Host machine are
not supported. With that settled, host.openPath answers the supported case
completely — a file:// document in a real browser has full page capabilities
and no reach into /api — and the HTTP serving this branch had built answered
only the unsupported one.

Removed: the /f route and its listener, the workspace-file URL shape,
ApiProxy.workspaceRootOf, ConnectionHandle.fileUrl, and the port published
into the index page.

Kept, and finished:
- the produced-files row a turn ends with, derived from mutation locations;
- the path link now reads as a link at rest, not only on hover — the reported
  "I can't open what it made" was this, sitting on a working capability;
- the Host opener prefers the default BROWSER for .html/.htm/.xhtml/.svg, so
  a developer who binds .html to an editor still gets a rendered page
  (macOS via the LaunchServices https handler, Linux via $BROWSER, every
  failure falling back to the default application).

The retired designs and their measurements stay in the Agent Note, including
why same-origin serving was unsafe and why the sandbox that fixed it broke
the pages invisibly.
This commit is contained in:
ZiyaZhang
2026-08-01 03:15:54 -07:00
parent 59bfe77fb8
commit 8fb6c2bd69
50 changed files with 317 additions and 1211 deletions
-15
View File
@@ -2290,20 +2290,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
pending.resolve(payload.answer)
return Promise.resolve({ accepted: true })
},
async workspaceRootOf(sessionId: SessionId): Promise<string | undefined> {
// A live agent answers from its own header; otherwise the store answers,
// deliberately without resuming — reading a session's directory must not
// pull an agent up the way the cold RPC path does.
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live.session.header.cwd
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return undefined
// TODO(persistence/by-id): a full listing per lookup. Harmless while the
// caller is one preview open, but a served document with N relative
// sub-resources pays it N times; a by-id header read on the persistence
// seam would retire it.
return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd
},
}
}
-98
View File
@@ -1,98 +0,0 @@
/**
* The `/f` workspace-file URL shape: the contract half of the web transport
* that carries bytes rather than RPC. The browser turns a tool's file path
* into a URL, the serving side turns that URL back into the segments below a
* session's cwd, and both read this one encoding decision so neither can drift
* into serving a path the other never meant. Pure string work with no Node and
* no DOM, like the rest of `api/` — the browser bundle inlines it.
* @module @deepseek-ai/dsh-host-apiproxy/api/files
*/
/**
* Route prefix owning every workspace-file read (`/f/<sessionId>/<segments…>`).
* The path carries the segments verbatim rather than a query parameter so a
* served document's relative references (`./logo.png`) resolve to their
* siblings in the same workspace directory.
*/
export const FILES_PATH = '/f'
/** One parsed workspace-file request: whose workspace, and where inside it. */
export interface WorkspaceFileTarget {
/** The owning session, still an opaque string — the caller resolves it to a cwd. */
sessionId: string
/** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */
segments: string[]
}
/** A segment that survived decoding but would re-enter path resolution as more than one name. */
function isPlainSegment(segment: string): boolean {
return segment !== '' && segment !== '.' && segment !== '..'
&& !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0')
}
function decode(raw: string): string | undefined {
try {
return decodeURIComponent(raw)
} catch {
// A malformed %-escape is a request we cannot interpret, not a miss.
return undefined
}
}
/**
* Express one tool-reported file path as segments below the session cwd.
* @param cwd - the session's working directory, or `undefined` when unknown.
* @param path - the path the tool reported (absolute, or relative to `cwd`).
* @returns the segments below `cwd`, or `undefined` when the path names
* something outside the workspace (which this route never serves) or resolves
* to the workspace directory itself.
*/
export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined {
const slashed = path.replace(/\\/g, '/')
const absolute = /^\/|^[A-Za-z]:\//.test(slashed)
let relative: string
if (absolute) {
if (cwd === undefined || cwd === '') return undefined
const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
if (!slashed.startsWith(`${root}/`)) return undefined
relative = slashed.slice(root.length + 1)
} else {
relative = slashed
}
const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.')
if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined
return segments
}
/**
* Build the origin-relative URL serving one workspace file.
* @param sessionId - the session whose cwd anchors the path.
* @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them.
* @returns the `/f/…` URL, resolved by the browser against the serving origin.
*/
export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string {
const encoded = segments.map(segment => encodeURIComponent(segment)).join('/')
return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}`
}
/**
* Parse a request pathname back into the session and segments it names.
* @param pathname - the request's raw (still percent-encoded) pathname.
* @returns the target, or `undefined` when the pathname is not a well-formed
* workspace-file read — including every traversal shape, which is refused here
* before any filesystem call rather than being resolved and then judged.
*/
export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined {
if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined
const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/')
if (rawSession === undefined || rawSegments.length === 0) return undefined
const sessionId = decode(rawSession)
if (sessionId === undefined || sessionId === '') return undefined
const segments: string[] = []
for (const raw of rawSegments) {
const segment = decode(raw)
if (segment === undefined || !isPlainSegment(segment)) return undefined
segments.push(segment)
}
return { sessionId, segments }
}
-17
View File
@@ -15,9 +15,6 @@ 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 {
@@ -33,17 +30,6 @@ 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 ----
@@ -63,9 +49,6 @@ 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) ----
-2
View File
@@ -64,7 +64,6 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly llm: ApiProxy['llm']
readonly events: ApiProxy['events']
readonly respond: ApiProxy['respond']
readonly workspaceRootOf: ApiProxy['workspaceRootOf']
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
@@ -88,7 +87,6 @@ export class ApiProxyService extends Service implements ApiProxy {
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
this.respond = api.respond.bind(api)
this.workspaceRootOf = api.workspaceRootOf.bind(api)
}
}
@@ -1,5 +1,16 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
/**
* Cross-platform open-with-default-application used by the local GUI carrier.
*
* A document a browser RENDERS is opened with the user's default browser
* rather than the default application for its type, when the platform can name
* one: a developer who binds `.html` to an editor would otherwise click a
* produced page and get source code. The contract is uniform — prefer the
* default browser, fall back to the default application — while how completely
* a platform can answer "which browser" differs, and every failure falls back
* rather than surfacing.
*/
import { extname } from 'node:path'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
@@ -9,6 +20,60 @@ export type PathOpenerRunner = NativeCommandRunner
export interface PathOpenerInternals {
platform?: NodeJS.Platform
run?: PathOpenerRunner
/** Environment the linux browser convention reads; defaults to the process env. */
env?: NodeJS.ProcessEnv
}
/** Documents a browser renders, as opposed to ones an editor merely edits. */
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
/**
* The macOS bundle registered for `https` — the default browser, as
* LaunchServices records it. The nested version dict is stripped first
* because it carries its own `LSHandlerRoleAll`.
*/
function macBundleForHttps(plist: string): string | undefined {
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
if (block === undefined) return undefined
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
}
/**
* Open one browser-renderable document with the default browser.
* @returns true when a browser took it; false when this platform cannot name
* one, or naming it failed — the caller then uses the default application.
*/
async function openInBrowser(
path: string, signal: AbortSignal, platform: NodeJS.Platform,
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
): Promise<boolean> {
if (platform === 'darwin') {
let bundle: string | undefined
try {
const { stdout } = await run(
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
bundle = macBundleForHttps(stdout)
} catch {
// No LaunchServices record (a fresh account never changed a default):
// the content-type handler is then the system's own choice anyway.
return false
}
if (bundle === undefined) return false
await run('open', ['-b', bundle, path], signal)
return true
}
if (platform === 'linux') {
// $BROWSER is the portable convention; desktop-entry resolution through
// xdg-settings needs a launcher this package has no business shipping.
const browser = env.BROWSER
if (browser === undefined || browser === '') return false
await run(browser, [path], signal)
return true
}
// Windows names no browser without reading the UserChoice registry, and its
// .html association is the browser in the ordinary case.
return false
}
/** PowerShell single-quoted literal (doubles embedded quotes). */
@@ -17,10 +82,11 @@ function powershellLiteral(path: string): string {
}
/**
* Open a filesystem path with the operating system's default application.
* Open a filesystem path with the operating system's default application, or
* with the default browser when the path names a document a browser renders.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @param internals - platform, environment, and runner seam for deterministic tests.
*/
export async function openNativePath(
path: string,
@@ -29,6 +95,10 @@ export async function openNativePath(
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
const env = internals.env ?? process.env
if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
&& await openInBrowser(path, signal, platform, run, env)) return
if (platform === 'darwin') {
await run('open', [path], signal)