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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40
|
||||
README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a
|
||||
README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5
|
||||
README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f
|
||||
|
||||
@@ -36,8 +36,6 @@ The `command.*` and `skill.*` domains expose the host command registry and skill
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md).
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
|
||||
@@ -36,8 +36,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) ----
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,11 +62,7 @@ function stubAgent(session: Session): Agent {
|
||||
async function harness(
|
||||
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
||||
extras: {
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */
|
||||
persisted?: { id: SessionId; cwd?: string }[] | 'absent'
|
||||
} = {},
|
||||
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -77,10 +73,7 @@ async function harness(
|
||||
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', storageDomain)
|
||||
ctx.provide('storageDomain', storageDomain)
|
||||
if (extras.persisted !== 'absent') {
|
||||
const persisted = extras.persisted ?? []
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never)
|
||||
}
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
@@ -251,27 +244,6 @@ describe('host.openPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceRootOf', () => {
|
||||
it('answers from the live agent, then the store, and names nothing for an unknown session', async () => {
|
||||
const { api, workspaceRoot } = await harness(undefined, undefined, {
|
||||
persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }],
|
||||
})
|
||||
const created = await api.sessions.create(request({ cwd: workspaceRoot }))
|
||||
const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId
|
||||
// Live: the agent's own header, no store read involved.
|
||||
await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot)
|
||||
// Not live: the store answers, and the lookup never resumes an agent —
|
||||
// this harness's factory throws on resume, so a resuming lookup would fail.
|
||||
await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold')
|
||||
await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('names nothing at all when the host keeps no session store', async () => {
|
||||
const { api } = await harness(undefined, undefined, { persisted: 'absent' })
|
||||
await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace.create', () => {
|
||||
it('serializes concurrent names and rejects the duplicate', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
|
||||
@@ -108,8 +108,6 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
// No wire face, so the handler map never reaches it.
|
||||
workspaceRootOf: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,8 +233,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
|
||||
},
|
||||
// No wire face, so the carrier never reaches it.
|
||||
workspaceRootOf: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/** The /f URL shape: one encoding decision, asserted from both ends. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl,
|
||||
} from '../src/api/files.ts'
|
||||
|
||||
describe('workspaceFileSegments', () => {
|
||||
it('keeps a relative path as its own segments', () => {
|
||||
expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html'])
|
||||
expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html'])
|
||||
expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt'])
|
||||
})
|
||||
|
||||
it('strips the cwd prefix from an absolute path inside the workspace', () => {
|
||||
expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html'])
|
||||
// A trailing separator on the cwd must not shift the split.
|
||||
expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html'])
|
||||
})
|
||||
|
||||
it('reads Windows paths on either separator', () => {
|
||||
expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html'])
|
||||
expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html'])
|
||||
})
|
||||
|
||||
it('refuses everything the route would not serve', () => {
|
||||
// Absolute, but not under this workspace.
|
||||
expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined()
|
||||
// A sibling directory sharing the cwd's name prefix is not inside it.
|
||||
expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined()
|
||||
// Absolute with no cwd to anchor against.
|
||||
expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined()
|
||||
expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined()
|
||||
// Traversal, in either spelling.
|
||||
expect(workspaceFileSegments('/w', '../secret')).toBeUndefined()
|
||||
expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined()
|
||||
// The workspace directory itself is not a file.
|
||||
expect(workspaceFileSegments('/w', '/w')).toBeUndefined()
|
||||
expect(workspaceFileSegments('/w', '.')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceFileUrl', () => {
|
||||
it('percent-encodes each segment but keeps the separators structural', () => {
|
||||
expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`)
|
||||
expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorkspaceFilePath', () => {
|
||||
it('round-trips what the browser half builds', () => {
|
||||
const url = workspaceFileUrl('s-1', ['out', 'a b.html'])
|
||||
expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] })
|
||||
})
|
||||
|
||||
it('refuses malformed, prefix-foreign, and traversal pathnames', () => {
|
||||
expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined()
|
||||
// Session named but no file below it.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined()
|
||||
// Traversal is refused at parse time, before any filesystem call.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined()
|
||||
// A separator smuggled through percent-encoding stays one segment's problem.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined()
|
||||
// Malformed percent-escapes are uninterpretable, not a miss to resolve.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -80,3 +80,96 @@ describe('native path opener', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser-renderable documents', () => {
|
||||
const LS_PLIST = `{
|
||||
LSHandlers = (
|
||||
{
|
||||
LSHandlerPreferredVersions = {
|
||||
LSHandlerRoleAll = "-";
|
||||
};
|
||||
LSHandlerRoleAll = "com.google.chrome";
|
||||
LSHandlerURLScheme = https;
|
||||
}
|
||||
);
|
||||
}`
|
||||
|
||||
it('opens a page with the default browser rather than the .html handler on darwin', async () => {
|
||||
const calls: { command: string; args: readonly string[] }[] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push({ command, args })
|
||||
return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
// A developer who bound .html to an editor still gets a rendered page.
|
||||
expect(calls.map(c => [c.command, ...c.args])).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '-b', 'com.google.chrome', '/w/page.html'],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves every other document to the default application', async () => {
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run })
|
||||
// No LaunchServices read at all: markdown is not a browser document.
|
||||
expect(calls).toEqual([['open', '/w/report.md']])
|
||||
})
|
||||
|
||||
it('falls back to the default application when no browser can be named', async () => {
|
||||
// LaunchServices has no https record (a fresh account), so the system's
|
||||
// own content-type choice is the best answer available.
|
||||
const calls: string[][] = []
|
||||
const run = async (command: string, args: readonly string[]) => {
|
||||
calls.push([command, ...args])
|
||||
if (command === 'defaults') throw new Error('domain not found')
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run })
|
||||
expect(calls).toEqual([
|
||||
['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'],
|
||||
['open', '/w/page.html'],
|
||||
])
|
||||
|
||||
// A record without an https handler is the same answer.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'darwin',
|
||||
run: async (command, args) => {
|
||||
bare.push([command, ...args])
|
||||
return { stdout: '{ LSHandlers = ( ); }', stderr: '' }
|
||||
},
|
||||
})
|
||||
expect(bare[1]).toEqual(['open', '/w/page.html'])
|
||||
})
|
||||
|
||||
it('honors $BROWSER on linux and leaves windows to its association', async () => {
|
||||
const linux: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
env: { BROWSER: 'firefox' },
|
||||
run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(linux).toEqual([['firefox', '/w/page.html']])
|
||||
|
||||
// Unset $BROWSER: xdg-open's association is the fallback.
|
||||
const bare: string[][] = []
|
||||
await openNativePath('/w/page.html', new AbortController().signal, {
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(bare).toEqual([['xdg-open', '/w/page.html']])
|
||||
|
||||
// Windows names no browser without the UserChoice registry.
|
||||
const win: string[][] = []
|
||||
await openNativePath('C:\\w\\page.html', new AbortController().signal, {
|
||||
platform: 'win32',
|
||||
run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } },
|
||||
})
|
||||
expect(win[0]?.[0]).toBe('powershell.exe')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user