diff --git a/packages/browser/bridge-browser/README.md b/packages/browser/bridge-browser/README.md index 78eaffbd..b1fa54c2 100644 --- a/packages/browser/bridge-browser/README.md +++ b/packages/browser/bridge-browser/README.md @@ -16,9 +16,14 @@ The **browser-operation bridge** for dsh: mounts a token-authenticated WebSocket | `maxInteractiveItems` | `number` | 60 | Upper bound on interactive inventory items per snapshot. | | `sessionWorkspacePath` | `string` | `~/.dsh/browser-sessions` | Dedicated Host Workspace for extension-created sessions. The plugin creates and idempotently registers the directory on the first implicit `session.create`; the session cwd becomes this path, so the GUI shows a `browser-sessions` workspace group. Set `""` to keep sessions Ungrouped. | | `deferSessionCreate` | `boolean` | `true` | Sessions materialize only on the first message: `session.create` answers with a provisional id (nothing persisted), history reads empty, and the first `session.prompt` creates the real session (same id, original payload). Opening the panel without chatting leaves zero trace in the session store/GUI. | +| `discoveryPort` | `number` | 43189 | Stable loopback port that republishes `/ext/bridge-config` for extensions that only probe fixed ports. `0` disables it. | Workspace grouping is best-effort. If the composition has no workspace domain, directory creation fails, or `workspace.create` rejects the path, the plugin logs one warning and sends every session creation without an injected workspace so browser chat remains usable. +### Port discovery + +The extension finds a local host by fetching `/ext/bridge-config` on a fixed port list (3080, 3081, 3090, 14389, 43189). That works for `dsh web`, which defaults to 3080, but `dsh web --port 0` — how DSH Desktop starts its embedded web server — asks the OS for a free port on every launch. The bridge therefore also binds `discoveryPort` (loopback only) and answers that one route there with the live WebSocket URL. The published URL carries no token; the WebSocket still authenticates loopback `chrome-extension://` clients by origin and requires the bearer token everywhere else. When the port is already taken — including the case where the web server itself runs on it — the listener is skipped with a single warning and boot continues. + ## Usage The remote installer downloads an installer-managed workspace, builds the plugin, and registers its official bundle in the local dsh `web` profile. It requires neither Git nor a local clone: diff --git a/packages/browser/bridge-browser/README.zh.md b/packages/browser/bridge-browser/README.zh.md index b41b6305..c34d7a76 100644 --- a/packages/browser/bridge-browser/README.zh.md +++ b/packages/browser/bridge-browser/README.zh.md @@ -16,9 +16,14 @@ dsh 的**浏览器操作桥**:在宿主 webserver 上挂载一个 **token 认 | `maxInteractiveItems` | `number` | 60 | 单次快照交互清单条数上限。 | | `sessionWorkspacePath` | `string` | `~/.dsh/browser-sessions` | 扩展创建的会话所用的专用 Host Workspace。插件会在首次调用未显式指定工作区的 `session.create` 时创建并幂等注册该目录;会话的 cwd 随之变为此路径,因此 GUI 会显示 `browser-sessions` 工作区分组。设为 `""` 可让会话继续显示在“未分组”中。 | | `deferSessionCreate` | `boolean` | `true` | 会话只在第一条消息时才真正创建:`session.create` 先返回一个内存暂定 ID(不落库),历史读取为空,第一次 `session.prompt` 才创建真实会话(同一 ID、回放原始创建参数)。只打开面板不说话,会在会话库/GUI 里不留任何痕迹。 | +| `discoveryPort` | `number` | 43189 | 稳定的回环端口,重新发布 `/ext/bridge-config`,供只探测固定端口表的扩展发现本机。`0` 关闭。 | 工作区分组采用尽力而为方式。如果组合没有 workspace 域、目录创建失败,或 `workspace.create` 拒绝该路径,插件会记录一条警告,并在不注入工作区的情况下发送所有会话创建请求,因此浏览器聊天仍可使用。 +### 端口发现 + +扩展通过在固定端口表(3080、3081、3090、14389、43189)上请求 `/ext/bridge-config` 来发现本机 dsh。这对默认监听 3080 的 `dsh web` 成立;但 DSH Desktop 以内嵌方式启动 Web 服务时用的是 `dsh web --port 0`,每次启动都由系统分配新端口,固定端口表就永远打不中。因此桥接插件额外绑定 `discoveryPort`(仅回环),只在该端口回答这一个路由,返回当前真实的 WebSocket 地址。发布的地址不含 token:WebSocket 仍然只对回环的 `chrome-extension://` 来源免 token,其它来源必须提供 bearer token。端口被占用时——包括 Web 服务本身就占用该端口——监听器直接跳过并记录一条警告,启动流程继续。 + ## 使用 远程安装器会下载一个由脚本托管的 workspace,构建插件,并将它的官方 bundle 注册到本机 dsh 的 `web` profile。该方式无需 Git,也无需提前 clone: diff --git a/packages/browser/bridge-browser/cordis.patch.yml b/packages/browser/bridge-browser/cordis.patch.yml index e29e8ad6..3c0356a3 100644 --- a/packages/browser/bridge-browser/cordis.patch.yml +++ b/packages/browser/bridge-browser/cordis.patch.yml @@ -14,6 +14,12 @@ # Sessions materialize only on the first message (deferSessionCreate defaults # to true): opening the side panel without chatting leaves no session behind. # +# Port discovery: `dsh web --port 0` (how DSH Desktop starts) reassigns the port +# carrying /ext/bridge on every launch, but the extension can only probe a fixed +# port list, so a dynamic host looks offline. The bridge therefore republishes +# /ext/bridge-config on the stable loopback port below. Set it to 0 to disable, +# or to another port the extension probes (3080 / 3081 / 3090 / 14389 / 19387). +# # Security: the bridge path carries its own token auth and refuses privileged # gateway methods (settings/credentials/open-*) for non-loopback remotes. Do # not run `dsh web --host 0.0.0.0` on untrusted networks with this layer. @@ -27,3 +33,4 @@ toolTimeoutMs: 90000 snapshotMaxChars: 32000 maxInteractiveItems: 60 + discoveryPort: 43189 diff --git a/packages/browser/bridge-browser/src/discovery.ts b/packages/browser/bridge-browser/src/discovery.ts new file mode 100644 index 00000000..ba9ec162 --- /dev/null +++ b/packages/browser/bridge-browser/src/discovery.ts @@ -0,0 +1,125 @@ +/** + * Fixed-port discovery listener. + * + * DSH Desktop starts its web server with `--port 0`, so the port that carries + * `/ext/bridge` is assigned by the OS on every boot. The extension can only + * probe a fixed port list, which makes a dynamically assigned port invisible: + * the side panel stays on "disconnected" while the bridge itself is healthy. + * + * This listener publishes the current bridge URL on one stable loopback port + * that the extension already probes. It does not move the real transport and + * does not widen the bridge's attack surface: the published URL carries no + * token, the listener answers one read-only route, and non-loopback deployment + * still authenticate the WebSocket with the bearer token. + * + * @module @yuxianglin/dsh-bridge-browser/src/discovery + */ + +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { BRIDGE_CONFIG_PATH, BRIDGE_PATH } from './protocol.ts' + +/** + * Stable loopback port the bundled extension probes for zero-config + * discovery. It matches the port the desktop user guide recommends pinning, so + * a deployment that already pins its web port keeps working unchanged. + */ +export const DEFAULT_DISCOVERY_PORT = 43189 + +/** Inputs for {@link startDiscoveryListener}. */ +export interface DiscoveryListenerOptions { + /** Port to bind. `0` asks the OS for a free port (tests); the plugin never passes 0. */ + port: number + /** Live web-server port; read per request so a restarted listener republishes itself. */ + webPort: () => number + /** Bind address. Loopback only by design; defaults to `127.0.0.1`. */ + host?: string + /** Warning sink. Discovery degrades instead of failing the boot. */ + warn?: (message: string) => void +} + +/** + * Build the bridge WebSocket URL published by the discovery route. + * @param webPort - port carrying `/ext/bridge`. + * @param host - loopback host to publish. + * @returns an absolute `ws://` URL. + */ +export function discoveryUrl(webPort: number, host = '127.0.0.1'): string { + return `ws://${host}:${String(webPort)}${BRIDGE_PATH}` +} + +/** + * Create the request handler for the discovery route. + * + * Only `GET`/`HEAD` on `BRIDGE_CONFIG_PATH` answer; everything else is a plain + * 404 so the listener never becomes a general-purpose proxy of the host. + * + * @param webPort - live web-server port. + * @param host - loopback host to publish. + * @returns a Node request handler. + */ +export function createDiscoveryHandler( + webPort: () => number, + host = '127.0.0.1', +): (req: IncomingMessage, res: ServerResponse) => void { + return (req, res) => { + const method = req.method ?? 'GET' + if (method !== 'GET' && method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD' }).end() + return + } + const pathname = (req.url ?? '/').split('?')[0] ?? '/' + if (pathname !== BRIDGE_CONFIG_PATH) { + res.writeHead(404).end() + return + } + const body = JSON.stringify({ wsUrl: discoveryUrl(webPort(), host) }) + res.writeHead(200, { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'content-length': String(Buffer.byteLength(body)), + }) + res.end(method === 'HEAD' ? undefined : body) + } +} + +/** + * Bind the discovery listener. + * + * Resolves `undefined` when discovery is unavailable: the port is already the + * web port (the ordinary route answers there), the port is taken by another + * process, or the bind fails. The caller keeps booting — a bridge without + * discovery still works once the extension is pointed at it manually. + * + * @param options - bind port, live web port, host, and warning sink. + * @returns the listening server, or `undefined` when discovery is skipped. + */ +export async function startDiscoveryListener(options: DiscoveryListenerOptions): Promise { + const host = options.host ?? '127.0.0.1' + const webPort = options.webPort() + if (options.port !== 0 && options.port === webPort) { + options.warn?.( + `bridge-browser: discovery port ${String(options.port)} is the web port; ${BRIDGE_CONFIG_PATH} already answers there`, + ) + return undefined + } + const server = createServer(createDiscoveryHandler(options.webPort, host)) + try { + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { reject(error) } + server.once('error', onError) + server.listen(options.port, host, () => { + server.off('error', onError) + resolve() + }) + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + options.warn?.( + `bridge-browser: discovery listener ${host}:${String(options.port)} unavailable (${reason});` + + ' set the extension bridge address manually', + ) + return undefined + } + return server +} diff --git a/packages/browser/bridge-browser/src/index.ts b/packages/browser/bridge-browser/src/index.ts index 1d77aa76..30aa5b09 100644 --- a/packages/browser/bridge-browser/src/index.ts +++ b/packages/browser/bridge-browser/src/index.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto' +import type { Server } from 'node:http' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-attachment' @@ -39,6 +40,7 @@ import { withSessionDeferral } from './session-deferral.ts' import { withSessionWorkspace } from './session-workspace.ts' import { purgeSessionFiles, type SessionPurgeDeps } from './session-purge.ts' import { resolveToken } from './token.ts' +import { DEFAULT_DISCOVERY_PORT, startDiscoveryListener } from './discovery.ts' import { createRemoteHostApi, type HostConnectionLike, @@ -81,6 +83,13 @@ export interface Config { sessionWorkspacePath?: string /** Defer real session creation until the first prompt. Defaults to true. */ deferSessionCreate?: boolean + /** + * Stable loopback port that republishes `/ext/bridge-config`, so an extension + * probing a fixed port list can still find a host whose web port is assigned + * dynamically (`dsh web --port 0`, which is how DSH Desktop starts). + * Defaults to 43189; `0` disables the listener. + */ + discoveryPort?: number } export const Config: z = z.object({ @@ -90,6 +99,7 @@ export const Config: z = z.object({ maxInteractiveItems: z.number().step(1).min(1).default(DEFAULT_MAX_INTERACTIVE_ITEMS), sessionWorkspacePath: z.string().default(DEFAULT_SESSION_WORKSPACE_PATH), deferSessionCreate: z.boolean().default(DEFAULT_DEFER_SESSION_CREATE), + discoveryPort: z.natural().max(65_535).default(DEFAULT_DISCOVERY_PORT), }) /** The shape after schemastery applies its defaults to every field. */ @@ -102,6 +112,13 @@ export function assertPositiveInteger(name: string, value: number): void { } } +/** An optional port must be a whole number in `0..65535` (`0` disables it). Exported for validation tests. */ +export function assertPortNumber(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 65_535) { + throw new Error(`bridge-browser: ${name} must be an integer between 0 and 65535`) + } +} + /** * Apply defaults and direct-call validation at the plugin boundary. * @param config - Loader-resolved or directly supplied plugin configuration. @@ -115,6 +132,7 @@ export function resolveConfig(config: Config): ResolvedConfig { maxInteractiveItems: config.maxInteractiveItems ?? DEFAULT_MAX_INTERACTIVE_ITEMS, sessionWorkspacePath: config.sessionWorkspacePath ?? DEFAULT_SESSION_WORKSPACE_PATH, deferSessionCreate: config.deferSessionCreate ?? DEFAULT_DEFER_SESSION_CREATE, + discoveryPort: config.discoveryPort ?? DEFAULT_DISCOVERY_PORT, } assertPositiveInteger('toolTimeoutMs', resolved.toolTimeoutMs) assertPositiveInteger('snapshotMaxChars', resolved.snapshotMaxChars) @@ -122,6 +140,7 @@ export function resolveConfig(config: Config): ResolvedConfig { throw new Error(`bridge-browser: snapshotMaxChars must be at least ${MIN_SNAPSHOT_MAX_CHARS}`) } assertPositiveInteger('maxInteractiveItems', resolved.maxInteractiveItems) + assertPortNumber('discoveryPort', resolved.discoveryPort) return resolved } @@ -249,6 +268,37 @@ function mountBridge( } ctx.effect(() => ctx.webServer.register(configRoute), 'bridge-browser: /ext/bridge-config route') + // Stable discovery port: DSH Desktop boots its web server with `--port 0`, so + // the port carrying `/ext/bridge` is reassigned on every launch while the + // extension can only probe a fixed list. Republishing the live URL on that + // fixed port keeps zero-config discovery working across restarts. + const discoveryPort = resolved.discoveryPort + if (discoveryPort > 0) { + let discoveryServer: Server | undefined + let discoveryStopped = false + void startDiscoveryListener({ + port: discoveryPort, + webPort: () => ctx.webServer.port, + warn: message => { ctx.logger.warn(message) }, + }).then((listener) => { + if (listener === undefined) return + if (discoveryStopped) { + listener.close() + return + } + discoveryServer = listener + ctx.logger.info( + `browser bridge: discovery published on http://127.0.0.1:${String(discoveryPort)}${BRIDGE_CONFIG_PATH}`, + ) + }).catch((error: unknown) => { + ctx.logger.warn(`bridge-browser: discovery listener failed: ${String(error)}`) + }) + ctx.effect(() => () => { + discoveryStopped = true + discoveryServer?.close() + }, 'bridge-browser: discovery listener') + } + ctx.effect(() => { const disposers = registerBrowserTools(ctx, server, { toolTimeoutMs: resolved.toolTimeoutMs, diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index e620d517..2d740054 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -44,6 +44,8 @@ export interface TypertGatewayLike { readonly method: string readonly args: Readonly> readonly signal?: AbortSignal + /** dsh 0.2.1 requires the calling Peer; 0.2.0 ignores the extra field. */ + readonly peer?: HostPeerLike | undefined }): Promise } @@ -65,19 +67,45 @@ const EMPTY_WIRE_UPLINK: AsyncIterable = { * these as three-arg maps AbortSignal onto uplink and leaves signal * undefined (hello.ok → stream-failed → WS 1011). */ -function openWireStream(gateway: TypertGatewayLike, endpoint: string, payload: unknown, signal: AbortSignal): Promise> { +function openWireStream( + gateway: TypertGatewayLike, + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer?: HostPeerLike, +): Promise> { const open = gateway.wireStream.open if (open.length === 3) { return open(endpoint, payload, signal) } - return open(endpoint, payload, EMPTY_WIRE_UPLINK, undefined, signal) + return open(endpoint, payload, EMPTY_WIRE_UPLINK, peer, signal) +} + +/** + * In-process Peer owned by this plugin's Context. + * + * dsh 0.2.1 requires every wire stream to be opened by a live Peer and binds a + * forwarded-event stream to that same Peer: `$events` aborts with + * `peer.signal` (undefined peer → TypeError → WS 1011), and `$events/result` + * rejects a result whose sender is a different Peer. dsh 0.2.0 has no Peer + * seam, so the plugin passes `undefined` there and keeps the old call shape. + */ +export interface HostPeerLike { + readonly signal: AbortSignal + readonly interactions: string + readonly principal: { readonly kind: string } } /** Structural subset of dsh 0.2's Host Connection service. */ export interface HostConnectionLike { createSharedFetchHandler(channel: '/api'): { - fetch(request: Request): Promise + fetch(request: Request, peer?: HostPeerLike): Promise } + /** dsh 0.2.1+: create the in-process owner Peer that opens wire streams. */ + createPeer?(options: { + readonly principal: { readonly kind: 'owner' } + readonly interactions: 'answer' + }): HostPeerLike } interface InvokeTarget { @@ -112,6 +140,8 @@ export function createRemoteHostApi( class RemoteHostApi implements BrowserHostApi { private readonly fetchHandler: ReturnType + /** dsh 0.2.1 in-process Peer; `undefined` on 0.2.0, which has no Peer seam. */ + private readonly peer: HostPeerLike | undefined private readonly extensionSessions = new ExtensionSessionRegistry() /** Last known session/follow tip per Session; drives session/page throughSeq. */ private readonly historyCursors = new Map() @@ -122,6 +152,9 @@ class RemoteHostApi implements BrowserHostApi { connection: HostConnectionLike, ) { this.fetchHandler = connection.createSharedFetchHandler('/api') + // The forwarded-event stream is bound to this Peer: results must come back + // from the same Peer, otherwise dsh 0.2.1 rejects them as another Peer's. + this.peer = connection.createPeer?.({ principal: { kind: 'owner' }, interactions: 'answer' }) } async call(call: HostRpcCall): Promise { @@ -145,6 +178,7 @@ class RemoteHostApi implements BrowserHostApi { method: target.method, args: target.args, signal: call.signal, + peer: this.peer, }) // Only claim ownership after a successful create/prompt. A failed prompt // against a Desktop session must not steal later ask_user_question away @@ -167,6 +201,7 @@ class RemoteHostApi implements BrowserHostApi { this.extensionSessions, this.noteHistoryCursor.bind(this), signal, + this.peer, ) const previous = this.activeEvents this.activeEvents = generation @@ -212,12 +247,13 @@ class RemoteHostApi implements BrowserHostApi { }, }, signal: call.signal, + peer: this.peer, }) return { ok: true, value: historyPageValue(page) } } const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages) + ? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages, this.peer) : await this.activeEvents.openSessionHistory(sessionId, call.signal, maxMessages) this.noteHistoryCursor(sessionId, snapshot.cursor) return { ok: true, value: historyValue(snapshot) } @@ -232,11 +268,11 @@ class RemoteHostApi implements BrowserHostApi { const sessionId = sessionIdOf(call.payload) try { const catalog = await this.gateway.invoke({ - namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal, + namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal, peer: this.peer, }) // Provisional Sessions request only the catalog and overlay their pending choice. const projections = sessionId === undefined ? undefined : await this.gateway.invoke({ - namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal, + namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal, peer: this.peer, }) const values = isRecord(projections) ? projections.values : undefined const modelSelection = isRecord(values) ? values.modelSelection : undefined @@ -255,7 +291,7 @@ class RemoteHostApi implements BrowserHostApi { const cached = this.historyCursors.get(sessionId) if (cached !== undefined) return cached const snapshot = this.activeEvents === undefined - ? await oneShotSessionSnapshot(this.gateway, sessionId, signal) + ? await oneShotSessionSnapshot(this.gateway, sessionId, signal, undefined, this.peer) : await this.activeEvents.openSessionHistory(sessionId, signal) this.noteHistoryCursor(sessionId, snapshot.cursor) const throughSeq = this.historyCursors.get(sessionId) @@ -277,7 +313,7 @@ class RemoteHostApi implements BrowserHostApi { try { const controller = new AbortController() const signal = AbortSignal.any([call.signal, controller.signal]) - const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal) + const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal, this.peer) const iterator = source[Symbol.asyncIterator]() try { const first = await iterator.next() @@ -320,7 +356,7 @@ class RemoteHostApi implements BrowserHostApi { }), signal, }) - const response = await this.fetchHandler.fetch(request) + const response = await this.fetchHandler.fetch(request, this.peer) if (!response.ok) { throw new Error(`$events/result transport failed with HTTP ${String(response.status)}: ${await response.text()}`) } @@ -380,6 +416,7 @@ class EventGeneration { private readonly extensionSessions: ExtensionSessionRegistry, private readonly onHistoryCursor: (sessionId: string, cursor: number) => void, outerSignal: AbortSignal, + private readonly peer?: HostPeerLike, ) { this.signal = AbortSignal.any([outerSignal, this.lifetime.signal]) } @@ -454,6 +491,7 @@ class EventGeneration { }, }, signal, + this.peer, ) const iterator = source[Symbol.asyncIterator]() const first = await iterator.next() @@ -549,7 +587,7 @@ class EventGeneration { private async pumpRemoteEvents(): Promise { try { - const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal) + const source = await openWireStream(this.gateway, '$events', { args: {} }, this.signal, this.peer) let ready = false for await (const value of source) { if (!ready) { @@ -739,6 +777,7 @@ async function oneShotSessionSnapshot( sessionId: string, outerSignal: AbortSignal, maxMessages?: number, + peer?: HostPeerLike, ): Promise { const controller = new AbortController() const signal = AbortSignal.any([outerSignal, controller.signal]) @@ -754,6 +793,7 @@ async function oneShotSessionSnapshot( }, }, signal, + peer, ) const iterator = source[Symbol.asyncIterator]() try { diff --git a/packages/browser/bridge-browser/tests/discovery.spec.ts b/packages/browser/bridge-browser/tests/discovery.spec.ts new file mode 100644 index 00000000..30bdea1c --- /dev/null +++ b/packages/browser/bridge-browser/tests/discovery.spec.ts @@ -0,0 +1,105 @@ +import { createServer } from 'node:http' +import type { Server, ServerResponse } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { + DEFAULT_DISCOVERY_PORT, + createDiscoveryHandler, + discoveryUrl, + startDiscoveryListener, +} from '../src/discovery.ts' + +const open: Server[] = [] + +afterEach(async () => { + for (const server of open.splice(0)) { + await new Promise((resolve) => { server.close(() => { resolve() }) }) + } +}) + +/** Start a server on an OS-assigned port and remember it for cleanup. */ +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { resolve() }) + }) + open.push(server) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port') + return address.port +} + +describe('discoveryUrl', () => { + it('builds the loopback bridge URL the extension probes', () => { + expect(discoveryUrl(10472)).toBe('ws://127.0.0.1:10472/ext/bridge') + }) +}) + +describe('createDiscoveryHandler', () => { + it('publishes the live web port on every request', async () => { + let webPort = 10472 + const port = await listen(createServer(createDiscoveryHandler(() => webPort))) + const first = await fetch(`http://127.0.0.1:${String(port)}/ext/bridge-config`) + expect(first.status).toBe(200) + expect(first.headers.get('cache-control')).toBe('no-store') + expect(await first.json()).toEqual({ wsUrl: 'ws://127.0.0.1:10472/ext/bridge' }) + + webPort = 20481 + const second = await fetch(`http://127.0.0.1:${String(port)}/ext/bridge-config`) + expect(await second.json()).toEqual({ wsUrl: 'ws://127.0.0.1:20481/ext/bridge' }) + }) + + it('answers HEAD without a body and rejects everything else', async () => { + const port = await listen(createServer(createDiscoveryHandler(() => 10472))) + const head = await fetch(`http://127.0.0.1:${String(port)}/ext/bridge-config`, { method: 'HEAD' }) + expect(head.status).toBe(200) + expect(await head.text()).toBe('') + + const post = await fetch(`http://127.0.0.1:${String(port)}/ext/bridge-config`, { method: 'POST' }) + expect(post.status).toBe(405) + + const missing = await fetch(`http://127.0.0.1:${String(port)}/ext/bridge`) + expect(missing.status).toBe(404) + }) +}) + +describe('startDiscoveryListener', () => { + it('binds the requested port and republishes the live bridge URL', async () => { + const warnings: string[] = [] + const server = await startDiscoveryListener({ + port: 0, + webPort: () => 10472, + warn: message => { warnings.push(message) }, + }) + if (server === undefined) throw new Error('expected a discovery listener') + open.push(server) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + const response = await fetch(`http://127.0.0.1:${String(address.port)}/ext/bridge-config`) + expect(await response.json()).toEqual({ wsUrl: 'ws://127.0.0.1:10472/ext/bridge' }) + expect(warnings).toEqual([]) + }) + + it('skips the listener when the discovery port is already the web port', async () => { + const warnings: string[] = [] + const server = await startDiscoveryListener({ + port: DEFAULT_DISCOVERY_PORT, + webPort: () => DEFAULT_DISCOVERY_PORT, + warn: message => { warnings.push(message) }, + }) + expect(server).toBeUndefined() + expect(warnings[0]).toMatch(/already answers there/) + }) + + it('degrades to a warning when the port is taken', async () => { + const blocker = createServer((_req, res: ServerResponse) => { res.writeHead(204).end() }) + const port = await listen(blocker) + const warnings: string[] = [] + const server = await startDiscoveryListener({ + port, + webPort: () => 10472, + warn: message => { warnings.push(message) }, + }) + expect(server).toBeUndefined() + expect(warnings[0]).toMatch(/unavailable/) + }) +}) diff --git a/packages/browser/bridge-browser/tests/index.spec.ts b/packages/browser/bridge-browser/tests/index.spec.ts index 08d0ee65..4a82ea6e 100644 --- a/packages/browser/bridge-browser/tests/index.spec.ts +++ b/packages/browser/bridge-browser/tests/index.spec.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { apply, assertPositiveInteger, Config, resolveConfig } from '../src/index.ts' +import { apply, assertPortNumber, assertPositiveInteger, Config, resolveConfig } from '../src/index.ts' +import { DEFAULT_DISCOVERY_PORT } from '../src/discovery.ts' /** Minimal context stub: apply only needs the services at registration time. */ function stubContext(): Context { @@ -49,17 +50,29 @@ describe('assertPositiveInteger', () => { }) }) +describe('assertPortNumber', () => { + it('accepts the optional-port range and rejects anything else', () => { + expect(() => assertPortNumber('x', 0)).not.toThrow() + expect(() => assertPortNumber('x', 65_535)).not.toThrow() + expect(() => assertPortNumber('x', -1)).toThrow(/between 0 and 65535/) + expect(() => assertPortNumber('x', 65_536)).toThrow(/between 0 and 65535/) + expect(() => assertPortNumber('x', 1.5)).toThrow(/between 0 and 65535/) + }) +}) + /** Valid budgets (the Loader applies schema defaults; hand-built tests pass them explicitly). */ -const VALID = { toolTimeoutMs: 90_000, snapshotMaxChars: 32_000, maxInteractiveItems: 60 } +const VALID = { toolTimeoutMs: 90_000, snapshotMaxChars: 32_000, maxInteractiveItems: 60, discoveryPort: 0 } describe('config', () => { it('resolves defaults, including an enabled workspace under the dsh home', () => { expect(resolveConfig({})).toEqual({ ...VALID, + discoveryPort: DEFAULT_DISCOVERY_PORT, sessionWorkspacePath: dshHomePath('browser-sessions'), deferSessionCreate: true, }) expect(new Config().sessionWorkspacePath).toBe(dshHomePath('browser-sessions')) + expect(new Config().discoveryPort).toBe(DEFAULT_DISCOVERY_PORT) }) it('preserves explicit values and the empty-string workspace opt-out', () => { @@ -70,6 +83,7 @@ describe('config', () => { maxInteractiveItems: 3, sessionWorkspacePath: '', deferSessionCreate: false, + discoveryPort: 43_190, })).toEqual({ token: 'fixed', toolTimeoutMs: 1, @@ -77,6 +91,7 @@ describe('config', () => { maxInteractiveItems: 3, sessionWorkspacePath: '', deferSessionCreate: false, + discoveryPort: 43_190, }) expect(new Config({ sessionWorkspacePath: '' }).sessionWorkspacePath).toBe('') }) diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 471e1c82..882f4481 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createRemoteHostApi, type HostConnectionLike, + type HostPeerLike, type TypertGatewayLike, } from '../src/remote-host-api.ts' import type { HostRpcCall } from '../src/host-api.ts' @@ -19,6 +20,7 @@ function harness(options: { invoke?: TypertGatewayLike['invoke'] open?: TypertGatewayLike['wireStream']['open'] fetch?: (request: Request) => Promise + peer?: HostPeerLike } = {}) { const invoke = vi.fn(options.invoke ?? (async () => ({ accepted: true }))) const open = vi.fn(options.open ?? (async (_endpoint, _payload, _uplink, _peer, signal) => ({ @@ -44,13 +46,33 @@ function harness(options: { }, }, } + const peer = options.peer const connection: HostConnectionLike = { createSharedFetchHandler: () => ({ fetch }), + ...(peer === undefined ? {} : { createPeer: () => peer }), } return { api: createRemoteHostApi(gateway, connection), invoke, open, fetch, requestBodies } } describe('dsh 0.2 Remote Host adapter', () => { + it('opens wire streams with the in-process owner Peer on dsh 0.2.1', async () => { + const peer: HostPeerLike = { + signal: new AbortController().signal, + interactions: 'answer', + principal: { kind: 'owner' }, + } + const { api, open } = harness({ peer }) + const controller = new AbortController() + const iterator = api.events(controller.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + await new Promise((resolve) => { setTimeout(resolve, 20) }) + // dsh 0.2.1 aborts with `peer.signal` before opening `$events`; the Peer + // must arrive in the fourth slot or the stream never opens. + expect(open).toHaveBeenCalledWith('$events', { args: {} }, expect.anything(), peer, expect.anything()) + controller.abort(new Error('test complete')) + await pending.catch(() => undefined) + }) + it.each([ { next: { provider: 'session-provider', model: 'saved-model', reasoningEffort: 'high' }, routable: true }, { next: { provider: 'removed-provider', model: 'saved-model' }, routable: false },