Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser
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/client/connection/README.md
|
||||
README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93
|
||||
README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756
|
||||
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
|
||||
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4
|
||||
|
||||
@@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯 `host[:port]` 权威的 `trustedHosts` 条目会让插件加载大声失败——否则 WHATWG 解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Browser-trust fence for every /api request. Defends the two confused-deputy
|
||||
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
|
||||
* the attacker's domain while the socket reaches this server) and cross-site
|
||||
* requests fired from a malicious page — without blocking non-browser clients
|
||||
* (no browser markers → no deputy to confuse, and a native client forges Host
|
||||
* freely anyway) or legitimately remote browsers (their authority is declared
|
||||
* via `trustedHosts`, or derived by the composing app for IP-literal LAN
|
||||
* serving). Network reachability and authentication stay out of scope: binding
|
||||
* policy belongs to the webserver config, and this fence is not an auth layer.
|
||||
* requests fired from a malicious page. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
|
||||
* Network reachability and authentication stay out of scope: binding policy
|
||||
* belongs to the webserver config, and this fence is not an auth layer.
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
@@ -42,30 +45,35 @@ function parseAuthority(authority: string): URL | undefined {
|
||||
|
||||
/**
|
||||
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
||||
* `host:port`) and nothing else. WHATWG parsing would quietly read a hostname
|
||||
* out of `harness.internal/path` or `user@harness.internal` — a typo must fail
|
||||
* the load loudly instead of authorizing its hostname or being ignored until
|
||||
* requests 403. The character test refuses every URL part beyond the authority
|
||||
* (path, backslash path, query, fragment, userinfo) and all whitespace, which
|
||||
* WHATWG trimming would otherwise strip silently; IPv6 brackets use none of
|
||||
* them.
|
||||
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
||||
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
||||
* that must fail the load loudly instead of being ignored until requests 403
|
||||
* or quietly changing the grant: URL parts beyond the authority
|
||||
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
||||
* the embedded hostname), stripped whitespace, a dangling colon or
|
||||
* zero-padded port (which would broaden an intended exact-port grant to every
|
||||
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
||||
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
||||
* carries).
|
||||
* @param entry - the configured value, verbatim.
|
||||
*/
|
||||
export function assertTrustedAuthority(entry: string): void {
|
||||
if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
|
||||
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parsed authority carries an explicit port: judged from URL
|
||||
* parses under both special schemes (their default ports differ, so `:80` and
|
||||
* `:443` still count as explicit), never from the raw string, where WHATWG
|
||||
* trimming of stray whitespace would misread `host:port ` as port-less and
|
||||
* broaden an exact-port grant to every port.
|
||||
* Canonical form of a parsed authority: `hostname` when no port was written,
|
||||
* else `hostname:port`. The port is judged from URL parses under both special
|
||||
* schemes (their default ports differ, so `:80` and `:443` still count as
|
||||
* explicit), never from the raw string, where WHATWG trimming would misread
|
||||
* shapes like `host:port ` as port-less.
|
||||
*/
|
||||
function hasExplicitPort(entry: string, entryUrl: URL): boolean {
|
||||
function canonicalAuthority(entry: string, entryUrl: URL): string {
|
||||
// An authority that parsed under http cannot fail under https.
|
||||
return entryUrl.port !== '' || new URL(`https://${entry}`).port !== ''
|
||||
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
|
||||
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,9 +87,9 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
return trustedHosts.some((entry) => {
|
||||
const entryUrl = parseAuthority(entry)
|
||||
if (entryUrl === undefined) return false
|
||||
return hasExplicitPort(entry, entryUrl)
|
||||
? entryUrl.host === hostUrl.host
|
||||
: entryUrl.hostname === hostUrl.hostname
|
||||
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
||||
? entryUrl.hostname === hostUrl.hostname
|
||||
: entryUrl.host === hostUrl.host
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,19 +97,16 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
||||
// Marker gate: Origin and sec-fetch-site exist only when a browser is the
|
||||
// sender's deputy. Absent both, the sender is the principal itself (curl,
|
||||
// tests, native shells) and could forge every header below — fencing it
|
||||
// would add nothing and would break non-browser LAN automation.
|
||||
const origin = header(request.headers, 'origin')
|
||||
const secFetchSite = header(request.headers, 'sec-fetch-site')
|
||||
if (origin === undefined && secFetchSite === undefined) return true
|
||||
// Host fence (DNS-rebinding defense): the browser fills Host from the URL it
|
||||
// believes it is talking to, so a rebound page carries the attacker's domain
|
||||
// here even though the socket lands on this server.
|
||||
// Host fence (DNS-rebinding defense), applied to every request: the browser
|
||||
// fills Host from the URL it believes it is talking to, so a rebound page
|
||||
// carries the attacker's domain here even though the socket lands on this
|
||||
// server. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
if (host === undefined) return false
|
||||
const hostUrl = parseAuthority(host)
|
||||
@@ -109,10 +114,12 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read
|
||||
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
||||
// Cross-site fence: modern browsers label the initiator relationship on
|
||||
// every fetch; an explicit cross-site marker is refused regardless of Origin.
|
||||
if (secFetchSite === 'cross-site') return false
|
||||
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
|
||||
// Origin fence: when a browser attaches an Origin it must be exactly this
|
||||
// authority (compared through the same normalization as the Host). The
|
||||
// authority (compared through the same normalization as the Host). Absent
|
||||
// Origin is fine — the Host fence above already bound the request. The
|
||||
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
|
||||
const origin = header(request.headers, 'origin')
|
||||
if (origin === undefined) return true
|
||||
try {
|
||||
return new URL(origin).host === hostUrl.host
|
||||
|
||||
@@ -831,7 +831,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
if (children === undefined) {
|
||||
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
|
||||
}
|
||||
const target = `${parent}/${request.payload.name}`
|
||||
// Same root special case as listDirectory's entry paths: a plain join
|
||||
// under '/' would mint '//name' and fork the tree's identity.
|
||||
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
|
||||
if (children.includes(request.payload.name)) {
|
||||
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ export interface ConnectionConfig {
|
||||
/**
|
||||
* Authorities this deployment serves beyond loopback: exact `host:port`, or
|
||||
* port-less `host` matching any port. The /api trust fence refuses any
|
||||
* browser request whose Host is neither loopback nor listed here, so a
|
||||
* request whose Host is neither loopback nor listed here, so a
|
||||
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
|
||||
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
|
||||
* that is not a bare authority fails the plugin load.
|
||||
* that is not a bare, canonical authority fails the plugin load.
|
||||
*/
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ function request(headers: Record<string, string | undefined>): { headers: Record
|
||||
}
|
||||
|
||||
describe('isTrustedApiRequest', () => {
|
||||
it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => {
|
||||
// No Origin and no sec-fetch-site → the sender is the principal itself
|
||||
// (it forges Host freely anyway); this is the LAN-serving shape a Host
|
||||
// fence must not break.
|
||||
for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) {
|
||||
expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true)
|
||||
}
|
||||
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
|
||||
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
|
||||
// reads (EventSource, images, navigations), so a rebound-origin GET is
|
||||
// markerless and its response readable: no marker shortcut may exist.
|
||||
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
|
||||
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
|
||||
expect(isTrustedApiRequest(request({}), [])).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
|
||||
@@ -79,6 +81,12 @@ describe('isTrustedApiRequest', () => {
|
||||
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
// WHATWG parsing would silently rewrite these — a dangling colon or
|
||||
// zero-padded port would broaden an intended exact-port grant to every
|
||||
// port, and non-canonical host spellings would not read back as written.
|
||||
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
|
||||
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
|
||||
|
||||
@@ -312,6 +312,22 @@ describe('createFixtureApi', () => {
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.path).toBe('/srv')
|
||||
const listed = await api.host.listDirectory(req({ path: '/srv' }))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.crumbs).toEqual([
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
])
|
||||
const root = await api.host.listDirectory(req({ path: '/' }))
|
||||
if (!root.result.ok) throw new Error('root list failed')
|
||||
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
|
||||
@@ -98,14 +98,14 @@ describe('connection node half', () => {
|
||||
})
|
||||
|
||||
it('passes loopback and declared-authority requests through to the bridge', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080'] })
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
|
||||
// Loopback, no browser markers (curl shape): the fence passes; the carrier
|
||||
// answers 404 for a GET unary path — proof the bridge ran.
|
||||
const loopback = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
|
||||
expect(loopback.state.status).toBe(404)
|
||||
// Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl
|
||||
// shape must reach the bridge even with an empty-by-default trust list.
|
||||
// LAN authority declared as a port-less IP literal — the shape the CLI
|
||||
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
|
||||
const lan = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
|
||||
expect(lan.state.status).toBe(404)
|
||||
|
||||
@@ -1603,9 +1603,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'DirectoryPickerBrowseCapability',
|
||||
declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise<DirectoryListing>;\n createDirectory(path: string, name: string): Promise<string>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DirectoryPickerCapabilities',
|
||||
declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DirectoryPickerCapability',
|
||||
declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;',
|
||||
declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];',
|
||||
},
|
||||
{
|
||||
name: 'DirectoryPickerDialogCapability',
|
||||
|
||||
@@ -17,7 +17,9 @@ export const hostDescribeValueSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]),
|
||||
// Open string, not a literal union: unknown kinds must survive the wire so
|
||||
// a merge-added capability can advertise (the client hides the affordance).
|
||||
directoryPicker: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
|
||||
@@ -11,8 +11,11 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
* the host display (`host.pickDirectory`); `browse` = in-app listing/creation
|
||||
* primitives (`host.listDirectory`/`host.createDirectory`). Calling a method
|
||||
* outside the advertised kind fails with `directory-picker-unavailable`.
|
||||
* The wire preserves kinds beyond the two with methods here (a merge-added
|
||||
* capability advertises before its RPCs exist); the client's documented
|
||||
* default for a kind it does not recognize is to hide the picking affordance.
|
||||
*/
|
||||
export type DirectoryPickerKind = 'dialog' | 'browse'
|
||||
export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {})
|
||||
|
||||
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
|
||||
export interface DirectoryEntry {
|
||||
|
||||
@@ -211,7 +211,9 @@ describe('host domain schemas', () => {
|
||||
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' })
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined()
|
||||
expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow()
|
||||
// A kind beyond the two with methods survives the wire (merge-added
|
||||
// capabilities advertise; the client hides the affordance).
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other')
|
||||
})
|
||||
|
||||
it('validates the browse listing/creation payloads', () => {
|
||||
|
||||
@@ -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/directory-picker/README.md
|
||||
README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868
|
||||
README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42
|
||||
README.md: 0332f7df067bfa79c7505be948554e814a690c6c
|
||||
README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime.
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime.
|
||||
|
||||
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。
|
||||
|
||||
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
|
||||
@@ -73,8 +73,18 @@ export interface DirectoryPickerBrowseCapability {
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
}
|
||||
|
||||
/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */
|
||||
export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability
|
||||
/**
|
||||
* Merge-extensible registry of interaction shapes keyed by capability kind: a
|
||||
* new backend declaration-merges its shape here (the entry's `kind` literal
|
||||
* must equal its key) instead of editing this package.
|
||||
*/
|
||||
export interface DirectoryPickerCapabilities {
|
||||
dialog: DirectoryPickerDialogCapability
|
||||
browse: DirectoryPickerBrowseCapability
|
||||
}
|
||||
|
||||
/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */
|
||||
export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities]
|
||||
|
||||
/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */
|
||||
export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'
|
||||
|
||||
Reference in New Issue
Block a user