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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ Local Chrome use requires no configuration; Firefox requires the local bridge to
- Verify the bridge is loaded: open `http://127.0.0.1:3080/ext/bridge-config`. It should return JSON such as `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}`. If it returns a web page instead of JSON, the running dsh predates the bridge registration — restart dsh and refresh the page; the extension reconnects on its own.
- The extension probes ports 3080, 3081, 3090, and 14389 automatically. If dsh runs on another port — or you use a remote `--host 0.0.0.0` deployment — set the address (and bridge token) in the panel settings. Firefox always requires the token.

**Remote `--host 0.0.0.0` / LAN deployments**

1. On the host, run dsh with a reachable bind (for example `dsh web --host 0.0.0.0`). `/ext/bridge-config` answers with a `wsUrl` derived from the request `Host` (and `X-Forwarded-*` when present), so `http://192.168.2.185:3080/ext/bridge-config` returns `ws://192.168.2.185:3080/ext/bridge` instead of loopback.
2. On the client, open the side-panel settings, set the bridge address to `ws://<host-ip>:3080/ext/bridge`, and paste the bridge token from `~/.dsh/ext-bridge-token` on the host. The extension `connect-src` CSP already allows any `ws`/`http(s)` host, so no rebuild is required.

Do not expose `dsh web --host 0.0.0.0` on untrusted networks. Optional: `EXT_CONNECT_SRC` can still append extra origins at build time if you need a tighter or custom CSP.

## Development

The bridge plugin and Chrome/Firefox extension are both members of this repository's workspace. Run all commands from the repository root. For the first development installation, run `pnpm install`.
Expand Down
7 changes: 7 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ Chrome 本机使用无需配置;Firefox 需要填写上述本地桥 token。
- 确认桥接已加载:浏览器打开 `http://127.0.0.1:3080/ext/bridge-config`,应返回类似 `{"wsUrl":"ws://127.0.0.1:3080/ext/bridge"}` 的 JSON。如果返回的是网页而不是 JSON,说明当前运行的 dsh 早于桥接注册——重启 dsh 并刷新页面即可,扩展会自动重连。
- 扩展会自动探测 3080/3081/3090/14389 端口。若 dsh 运行在其它端口,或使用 `--host 0.0.0.0` 远程部署,请在面板设置中填写地址与桥接 token。Firefox 始终需要 token。

**远程 `--host 0.0.0.0` / 局域网部署**

1. 主机用可被访问的绑定启动 dsh(例如 `dsh web --host 0.0.0.0`)。`/ext/bridge-config` 会按请求的 `Host`(以及存在时的 `X-Forwarded-*`)返回 `wsUrl`。访问 `http://192.168.2.185:3080/ext/bridge-config` 会得到 `ws://192.168.2.185:3080/ext/bridge`,而不再是回环地址。
2. 客户端打开侧栏设置,把桥地址写成 `ws://<主机IP>:3080/ext/bridge`,并粘贴主机上 `~/.dsh/ext-bridge-token` 的桥接 token。扩展默认 CSP 已允许任意 `ws`/`http(s)` 主机,无需为局域网 IP 重新打包。

不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。可选:构建时仍可用 `EXT_CONNECT_SRC` 追加额外 origin,以便收紧或定制 CSP。

## 开发

桥接插件和 Chrome/Firefox 扩展都属于本仓库 workspace;所有命令均在本仓库根目录执行。首次开发安装运行 `pnpm install`。
Expand Down
2 changes: 1 addition & 1 deletion extensions/dsh-browser/manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https://raw.githubusercontent.com"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws: wss: http: https:"
},
"icons": {
"16": "assets/icons/icon16.png",
Expand Down
2 changes: 1 addition & 1 deletion extensions/dsh-browser/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
}
],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:* http://127.0.0.1:* https://raw.githubusercontent.com"
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws: wss: http: https:"
},
"icons": {
"16": "assets/icons/icon16.png",
Expand Down
3 changes: 3 additions & 0 deletions extensions/dsh-browser/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* with --firefox):
* background (es|iife) → content (iife) → panel (React). The first target
* cleans the output; the later ones append. Pass --watch for dev rebuilds.
*
* Optional: EXT_CONNECT_SRC='…' appends extra connect-src tokens at copy time.
* Default manifests already allow any ws/http(s) host for LAN bridge URLs.
*/

import { spawn, spawnSync } from 'node:child_process'
Expand Down
21 changes: 21 additions & 0 deletions extensions/dsh-browser/src/connect-src.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Manifest CSP helpers used by the extension build and its unit tests.
* Kept free of Vite imports so vitest can load it under jsdom.
*/

/**
* Append space/comma-separated tokens to a CSP `connect-src` directive.
* Duplicates are skipped so rebuilds with the same EXT_CONNECT_SRC stay stable.
*/
export function appendConnectSrc(csp: string, extras: string): string {
const tokens = extras.split(/[\s,]+/).map((token) => token.trim()).filter((token) => token !== '')
if (tokens.length === 0) return csp
const match = /connect-src\s+([^;]+)/.exec(csp)
if (match === null) return csp
const existing = match[1]!.trim().split(/\s+/).filter((token) => token !== '')
const merged = [...existing]
for (const token of tokens) {
if (!merged.includes(token)) merged.push(token)
}
return csp.replace(/connect-src\s+[^;]+/, `connect-src ${merged.join(' ')}`)
}
8 changes: 4 additions & 4 deletions extensions/dsh-browser/src/panel/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ const EN: PanelCopy = {
eyebrow: 'Browser assistant',
title: 'Settings',
bridgeAddress: 'Bridge address',
bridgeHelp: 'Leave blank to detect a local service automatically',
bridgePlaceholder: 'Auto-detect 3080 / 3081 / 3090 / 14389 / 43189',
bridgeHelp: 'Leave blank to detect a local service, or set ws://<host-ip>:3080/ext/bridge for LAN',
bridgePlaceholder: 'ws://192.168.x.x:3080/ext/bridge',
tokenHelp: 'Required by Firefox and remote deployments',
tokenPlaceholder: 'Required for Firefox / remote deployments',
pageSharing: 'Page content sharing',
Expand Down Expand Up @@ -529,8 +529,8 @@ const ZH: PanelCopy = {
eyebrow: '浏览器助手',
title: '设置',
bridgeAddress: '桥地址',
bridgeHelp: '留空时自动检测本机服务',
bridgePlaceholder: '自动检测 3080 / 3081 / 3090 / 14389 / 43189',
bridgeHelp: '留空自动检测本机;局域网填写 ws://<主机IP>:3080/ext/bridge',
bridgePlaceholder: 'ws://192.168.x.x:3080/ext/bridge',
tokenHelp: 'Firefox 和远程部署需要填写',
tokenPlaceholder: 'Firefox / 远程部署时填写',
pageSharing: '页面内容共享',
Expand Down
19 changes: 18 additions & 1 deletion extensions/dsh-browser/tests/firefox-build.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import { appendConnectSrc } from '../src/connect-src.ts'

interface ExtensionManifest {
version: string
Expand Down Expand Up @@ -31,7 +32,7 @@ describe('Firefox build contract', () => {
expect(firefoxManifest.version).toBe(packageManifest.version)
expect(firefoxManifest.version).toBe(chromeManifest.version)
expect(firefoxManifest.permissions).toContain('notifications')
expect(firefoxManifest.content_security_policy.extension_pages).toContain('https://raw.githubusercontent.com')
expect(firefoxManifest.content_security_policy.extension_pages).toMatch(/\bhttps:/)
})

it('uses a Firefox event page, sidebar, and AMO data-transmission declaration', async () => {
Expand All @@ -50,4 +51,20 @@ describe('Firefox build contract', () => {
'websiteContent',
])
})

it('allows any ws/http(s) host via scheme sources so panel settings can target a LAN bridge', async () => {
const chromeManifest = await readJson<ExtensionManifest>('../manifest.json')
const base = chromeManifest.content_security_policy.extension_pages
expect(base).toContain('ws:')
expect(base).toContain('wss:')
expect(base).toContain('http:')
expect(base).toContain('https:')
expect(base).not.toContain('192.168.2.185')

const patched = appendConnectSrc(base, 'ws://192.168.2.185:* http://192.168.2.185:*')
expect(patched).toContain('ws:')
expect(patched).toContain('ws://192.168.2.185:*')
expect(patched).toContain('http://192.168.2.185:*')
expect(appendConnectSrc(patched, 'ws://192.168.2.185:*')).toBe(patched)
})
})
3 changes: 2 additions & 1 deletion extensions/dsh-browser/tests/updates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ describe('extension update checks', () => {
const installer = readFileSync(`${extensionRoot}/../../scripts/install.sh`, 'utf8')

expect(packageManifest.version).toBe(chromeManifest.version)
expect(chromeManifest.content_security_policy.extension_pages).toContain(new URL(UPDATE_MANIFEST_URL).origin)
// Update checks fetch UPDATE_MANIFEST_URL over https; scheme source covers any host.
expect(chromeManifest.content_security_policy.extension_pages).toMatch(/\bhttps:/)
expect(installer).toContain('INSTALL_MODE="managed"')
expect(installer).toContain('INSTALL_MODE="checkout"')
expect(installer).toContain('$DIST_DIR/install-info.json')
Expand Down
24 changes: 22 additions & 2 deletions extensions/dsh-browser/vite.shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { copyFileSync, cpSync, mkdirSync } from 'node:fs'
import { copyFileSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vite'
import { appendConnectSrc } from './src/connect-src.ts'

/**
* Shared build plumbing for the extension's three targets (background ES
Expand All @@ -19,12 +20,31 @@ export const targetManifest = browserTarget === 'firefox' ? 'manifest.firefox.js

export const outDir = resolve(import.meta.dirname, browserTarget === 'firefox' ? 'dist-firefox' : 'dist')

export { appendConnectSrc }

/** Copy manifest, locale catalogs, and icons into the target's outDir. */
export const copyManifest = {
name: 'copy-manifest',
closeBundle(): void {
mkdirSync(outDir, { recursive: true })
copyFileSync(resolve(import.meta.dirname, targetManifest), resolve(outDir, 'manifest.json'))
const source = resolve(import.meta.dirname, targetManifest)
const dest = resolve(outDir, 'manifest.json')
const extras = process.env.EXT_CONNECT_SRC?.trim() ?? ''
if (extras === '') {
copyFileSync(source, dest)
} else {
const manifest = JSON.parse(readFileSync(source, 'utf8')) as {
content_security_policy?: { extension_pages?: string }
}
const csp = manifest.content_security_policy?.extension_pages
if (typeof csp === 'string') {
manifest.content_security_policy = {
...manifest.content_security_policy,
extension_pages: appendConnectSrc(csp, extras),
}
}
writeFileSync(dest, `${JSON.stringify(manifest, null, 2)}\n`)
}
cpSync(resolve(import.meta.dirname, '_locales'), resolve(outDir, '_locales'), { recursive: true })
cpSync(resolve(import.meta.dirname, 'assets'), resolve(outDir, 'assets'), { recursive: true })
},
Expand Down
1 change: 1 addition & 0 deletions packages/browser/bridge-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The installer copies the unpacked extension to `~/.dsh/browser-extension` and op
- The bridge route lives **outside** the `/api` trust fence (which only guards client-connection's routes), so it carries its own bearer-token authentication: the first frame must be `hello` with the token within 5s, verified in constant time. Failed auth closes the socket.
- Gateway methods the `/api` carrier pins to loopback (`settings.*`, `credentials.*`, `host.pickDirectory`, `host.openPath`) are refused for non-loopback remotes **even with a valid token** — defense in depth for `--host 0.0.0.0` deployments.
- One active connection at a time; a new authenticated socket replaces the previous one.
- `/ext/bridge-config` derives `wsUrl` from the request `Host` (and `X-Forwarded-Host` / `X-Forwarded-Proto` when present) so LAN clients are not told to dial `127.0.0.1` on their own machine.
- The bridge is a confused-deputy boundary, not a general auth layer: never expose `dsh web --host 0.0.0.0` on untrusted networks.
- Extracted page text is marked as untrusted model input. Page reads honor the extension's ask/auto/off policy, while state-changing tools require an origin-scoped side-panel decision and fail closed without a panel. Same-origin repetition can be trusted for the current panel session; permanent trust remains an explicit setting.

Expand Down
1 change: 1 addition & 0 deletions packages/browser/bridge-browser/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ npx @deepseek-ai/dsh@0.2.0-rc.2 web
- 桥路径在 `/api` 信任栅栏**之外**(栅栏只罩 client-connection 注册的路由),因此自带 bearer token 认证:首帧必须是 `hello`(5 秒内),常量时间比对,失败即断开。
- `/api` 载体钉在回环上的方法(`settings.*`、`credentials.*`、`host.pickDirectory`、`host.openPath`)对非回环来源**即使 token 正确也拒绝**——对 `--host 0.0.0.0` 部署的纵深防御。
- 同一时刻仅一个活动连接,新认证连接顶替旧连接。
- `/ext/bridge-config` 按请求的 `Host`(以及存在时的 `X-Forwarded-Host` / `X-Forwarded-Proto`)生成 `wsUrl`,避免局域网客户端被要求去连本机 `127.0.0.1`。
- 桥是 confused-deputy 边界而非通用认证层:不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。
- 抽取的页面文字会标记为模型的不可信输入。页面读取遵循扩展的询问/自动/关闭策略;状态变更工具必须经过按 origin 的侧边栏决策,没有侧边栏时失败关闭。同源后续操作可只在当前侧栏会话中临时信任,永久信任仍需显式设置。

Expand Down
65 changes: 65 additions & 0 deletions packages/browser/bridge-browser/src/bridge-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,71 @@ export function bridgeWsUrlFromLocation(
return `${wsProtocol}//${host}${bridgePath}`
}

/** Options for reconstructing a bridge URL from an HTTP discovery request. */
export interface BridgeWsUrlFromHttpHostOptions {
/** Port used when `Host` is missing or omits one (the listening webServer port). */
fallbackPort: number
/** When true, emit `wss:` (TLS / `X-Forwarded-Proto: https`). */
secure?: boolean
bridgePath?: string
}

/**
* Build `ws(s)://…/ext/bridge` from an HTTP `Host` header.
*
* Used by `/ext/bridge-config` so LAN / `--host 0.0.0.0` clients receive a URL
* they can actually dial, instead of a hard-coded loopback address. Missing or
* unusable hosts fall back to `127.0.0.1:<fallbackPort>` (local discovery).
*/
export function bridgeWsUrlFromHttpHost(
hostHeader: string | undefined,
options: BridgeWsUrlFromHttpHostOptions,
): string {
const bridgePath = options.bridgePath ?? BRIDGE_PATH
const wsProtocol = options.secure === true ? 'wss:' : 'ws:'
const parsed = parseHttpHostHeader(hostHeader)
const hostname = parsed === undefined
? '127.0.0.1'
: parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname
const port = parsed?.port !== undefined && parsed.port !== ''
? parsed.port
: String(options.fallbackPort)
const hostLabel = hostname.includes(':') ? `[${hostname}]` : hostname
const host = port === '' ? hostLabel : `${hostLabel}:${port}`
return `${wsProtocol}//${host}${bridgePath}`
}

/** Split `Host` / `X-Forwarded-Host` into hostname + optional port. */
export function parseHttpHostHeader(
hostHeader: string | undefined,
): { hostname: string; port: string } | undefined {
const raw = hostHeader?.trim() ?? ''
if (raw === '') return undefined
// First value only when proxies send a comma-separated list.
const host = raw.split(',', 1)[0]!.trim()
if (host === '') return undefined

if (host.startsWith('[')) {
const end = host.indexOf(']')
if (end <= 1) return undefined
const hostname = host.slice(1, end)
if (hostname === '') return undefined
if (host[end + 1] === ':' && host.length > end + 2) {
return { hostname, port: host.slice(end + 2) }
}
return { hostname, port: '' }
}

const colon = host.lastIndexOf(':')
if (colon > 0 && host.indexOf(':') === colon) {
const hostname = host.slice(0, colon)
const port = host.slice(colon + 1)
if (hostname === '' || port === '') return undefined
return { hostname, port }
}
return { hostname: host, port: '' }
}

/**
* Prefer the discovery endpoint; fall back to reconstructing from `location`.
* @param fetchImpl - injectable fetch (defaults to global fetch).
Expand Down
38 changes: 32 additions & 6 deletions packages/browser/bridge-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
DEFAULT_SNAPSHOT_MAX_CHARS,
MIN_SNAPSHOT_MAX_CHARS,
} from './protocol.ts'
import { bridgeWsUrlFromHttpHost } from './bridge-url.ts'
import { withSessionDeferral } from './session-deferral.ts'
import { withSessionWorkspace } from './session-workspace.ts'
import { purgeSessionFiles, type SessionPurgeDeps } from './session-purge.ts'
Expand Down Expand Up @@ -235,16 +236,26 @@ function mountBridge(
// 异步 disposer:HMR/卸载时先等桥完全关闭(socket/泵/acceptor 静默)再继续。
ctx.effect(() => () => server.close(), 'bridge-browser: bridge server')

// Zero-config discovery endpoint: the extension fetches this to learn the
// bridge WebSocket URL without any manual configuration. The URL carries no
// secret (loopback connections skip the token); non-loopback deployments
// keep requiring the token on the WS itself.
// Zero-config discovery endpoint: the extension (or a LAN client) fetches
// this to learn the bridge WebSocket URL. The URL carries no secret
// (loopback connections skip the token); non-loopback deployments keep
// requiring the token on the WS itself. Prefer the request Host so
// `--host 0.0.0.0` remotes are not told to dial 127.0.0.1 on their own machine.
const configRoute: WebRoute = {
kind: 'exact',
path: BRIDGE_CONFIG_PATH,
handler: (_req, res) => {
handler: (req, res) => {
const hostHeader = firstHeaderValue(req.headers['x-forwarded-host'])
?? firstHeaderValue(req.headers.host)
const forwardedProto = firstHeaderValue(req.headers['x-forwarded-proto'])
const secure = forwardedProto === 'https'
|| (req.socket as { encrypted?: boolean }).encrypted === true
const wsUrl = bridgeWsUrlFromHttpHost(hostHeader, {
fallbackPort: ctx.webServer.port,
secure,
})
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ wsUrl: `ws://127.0.0.1:${ctx.webServer.port}${BRIDGE_PATH}` }))
res.end(JSON.stringify({ wsUrl }))
},
}
ctx.effect(() => ctx.webServer.register(configRoute), 'bridge-browser: /ext/bridge-config route')
Expand Down Expand Up @@ -283,6 +294,21 @@ type GatewayCandidate = Pick<TypertGatewayLike, 'invoke'> & {
readonly wireStream?: TypertGatewayLike['wireStream']
}

/** First value of an HTTP header that may be a string or string[]. */
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
if (Array.isArray(value)) {
for (const entry of value) {
const trimmed = entry.trim()
if (trimmed !== '') return trimmed
}
}
return undefined
}

/** Check the minimum supported Gateway contract before mounting the bridge. */
function hasRemoteWireStream(gateway: GatewayCandidate): gateway is TypertGatewayLike {
return gateway.wireStream !== undefined
Expand Down
Loading
Loading