Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/browser/bridge-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/bridge-browser/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/bridge-browser/cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,3 +33,4 @@
toolTimeoutMs: 90000
snapshotMaxChars: 32000
maxInteractiveItems: 60
discoveryPort: 43189
125 changes: 125 additions & 0 deletions packages/browser/bridge-browser/src/discovery.ts
Original file line number Diff line number Diff line change
@@ -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<Server | undefined> {
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<void>((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
}
50 changes: 50 additions & 0 deletions packages/browser/bridge-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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<Config> = z.object({
Expand All @@ -90,6 +99,7 @@ export const Config: z<Config> = 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. */
Expand All @@ -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.
Expand All @@ -115,13 +132,15 @@ 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)
if (resolved.snapshotMaxChars < MIN_SNAPSHOT_MAX_CHARS) {
throw new Error(`bridge-browser: snapshotMaxChars must be at least ${MIN_SNAPSHOT_MAX_CHARS}`)
}
assertPositiveInteger('maxInteractiveItems', resolved.maxInteractiveItems)
assertPortNumber('discoveryPort', resolved.discoveryPort)
return resolved
}

Expand Down Expand Up @@ -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,
Expand Down
Loading