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: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ or, on Windows:
$s="$env:TEMP\dsh-install.ps1"; irm https://raw.githubusercontent.com/Lum1104/dsh-browser/refs/heads/main/scripts/install.ps1 -OutFile $s; powershell -NoProfile -ExecutionPolicy Bypass -File $s
```

The installer downloads `main`, builds and registers the bridge plugin, builds the Chrome extension into `~/.dsh/browser-extension`, and opens `chrome://extensions`. On the first install, load that directory as an unpacked extension; on updates, click **Reload**. Restart dsh if it is already running.
The installer downloads `main`, builds and registers the bridge plugin, builds the Chrome extension into `~/.dsh/browser-extension`, and opens `chrome://extensions`. On the first install, load that directory as an unpacked extension; on updates, click **Reload**. Restart dsh if it is already running. Note for updates from pre-0.1.5 builds: the extension now pins a stable manifest `key`, so its id (and the per-id stored settings) change once — re-enter the bridge address and token in the panel if you had configured a remote bridge.

`scripts/install.sh` covers macOS and Linux, and `scripts/install.ps1` covers Windows; both write the same managed workspace and the same install metadata. The installer copies the extension path to the clipboard when a clipboard tool is available (`pbcopy`, `wl-copy`, `xclip`, `xsel`, or PowerShell's `Set-Clipboard`), and prints the path either way. When no Chrome or Chromium install is found, it prints the command that installs one; set `DSH_INSTALL_BROWSER=1` to let the installer attempt that install itself.

Expand Down Expand Up @@ -183,7 +183,8 @@ If you encounter `cache.hydratePrepared is not a function`, update the repositor
## Security

- The bridge path sits outside the `/api` trust boundary and performs its own bearer-token authentication.
- Local Chrome extension origins retain zero-configuration loopback access; Firefox origins are per-install UUIDs and must present the bearer token.
- Zero-config loopback access is pinned to this extension's stable id (derived from the public manifest `key`); unlisted extensions must present the bearer token. The key is public, so the pin stops *incidental* cross-extension access — an extension deliberately copying the key inherits the id. Deployments that must resist hostile extensions should set `trustedExtensionOrigins: []` and pair the bearer token. Firefox origins are per-install UUIDs and always present the token.
- The exemption is loopback-only. A reverse proxy terminating on 127.0.0.1 (`tailscale serve`, `ssh -L`, nginx) makes proxied clients appear as loopback — use the hardened mode above for such deployments.
- Privileged gateway methods such as `settings.*`, `credentials.*`, and `host.open*` reject non-loopback sources.
- The browser-page pipeline is text-only and never captures screenshots; explicitly attached chat images use dsh's durable attachment service. Password and payment-card values never leave the page.
- When work begins, the assistant binds to the active tab (at prompt submission, or at the first direct browser-tool call). If you switch tabs manually, later browser actions pause and the side panel asks whether the assistant should continue on the original tab or follow the new one. Choosing the original tab permits background operation; the extension never silently retargets or changes your visible tab. Closing the controlled tab also pauses tools until you explicitly select the current page.
Expand Down
5 changes: 3 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Windows 请运行:
$s="$env:TEMP\dsh-install.ps1"; irm https://raw.githubusercontent.com/Lum1104/dsh-browser/refs/heads/main/scripts/install.ps1 -OutFile $s; powershell -NoProfile -ExecutionPolicy Bypass -File $s
```

安装器会下载 `main`、构建并注册桥插件、把 Chrome 扩展构建到 `~/.dsh/browser-extension`,然后打开 `chrome://extensions`。首次安装时,请把该目录作为已解压扩展加载;更新时点击**重新加载**。如果 dsh 已在运行,请重启。
安装器会下载 `main`、构建并注册桥插件、把 Chrome 扩展构建到 `~/.dsh/browser-extension`,然后打开 `chrome://extensions`。首次安装时,请把该目录作为已解压扩展加载;更新时点击**重新加载**。如果 dsh 已在运行,请重启。从 0.1.5 之前的版本更新请注意:扩展固定了 manifest `key`,其 id(及按 id 存储的设置)会变更一次——如曾配置远程桥地址与 token,请在面板中重新填写。

`scripts/install.sh` 覆盖 macOS 与 Linux,`scripts/install.ps1` 覆盖 Windows;两者写入同一个托管工作区和同一份安装元数据。当系统提供剪贴板工具(`pbcopy`、`wl-copy`、`xclip`、`xsel` 或 PowerShell 的 `Set-Clipboard`)时,安装器会把扩展路径复制到剪贴板;无论是否复制成功都会打印该路径。若未检测到 Chrome/Chromium,安装器会打印对应的安装命令;设置 `DSH_INSTALL_BROWSER=1` 可让安装器尝试自动安装。

Expand Down Expand Up @@ -183,7 +183,8 @@ pnpm --filter dsh-browser-extension run test
## 安全

- 桥路径在 `/api` 信任栅栏之外,自带 bearer token 认证。
- Chrome 扩展的本地 Origin 保留零配置回环访问;Firefox Origin 是每次安装生成的 UUID,必须携带 bearer token。
- 零配置回环访问只授予本扩展的固定 id(由公开的 manifest `key` 派生);未列入的扩展必须携带 bearer token。该 key 是公开的,因此这只防*误连*其它扩展——刻意复制 key 的扩展会得到相同 id。需要抵御恶意扩展的部署应设置 `trustedExtensionOrigins: []` 并强制 token 配对。Firefox Origin 是每次安装生成的 UUID,始终需要携带 token。
- 该豁免仅限回环。在 127.0.0.1 上终结连接的反向代理(`tailscale serve`、`ssh -L`、nginx)会让代理客户端表现为回环——此类部署请使用上述加固模式。
- 特权网关方法(`settings.*`/`credentials.*`/`host.open*`)对非回环来源一律拒绝。
- 单活动连接;浏览器页面管线为纯文本且不截图;用户主动添加的对话图片交给 dsh 持久附件服务,密码和卡号值永不回传。
- 助手开始工作时会绑定当时的活动标签页(提交提示时绑定;直接调用浏览器工具时则在首次调用绑定)。用户手动切页后,后续浏览器操作会暂停,侧栏会询问让助手继续原页面还是跟随新页面;选择原页面后允许在后台继续,但扩展绝不静默改绑或切换用户正在看的页面。受控标签页关闭后也会暂停,直到用户显式选择当前页。
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 @@ -3,7 +3,7 @@
"name": "__MSG_extensionName__",
"description": "__MSG_extensionDescription__",
"default_locale": "en",
"version": "0.1.4",
"version": "0.1.5",
"browser_specific_settings": {
"gecko": {
"id": "dsh-browser@lum1104.github.io",
Expand Down
3 changes: 2 additions & 1 deletion extensions/dsh-browser/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"name": "__MSG_extensionName__",
"description": "__MSG_extensionDescription__",
"default_locale": "en",
"version": "0.1.4",
"version": "0.1.5",
"minimum_chrome_version": "116",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqzAPQcLNodZ9dwaOafZpmUMtFRDi7nO93D3pft0ptPa+N3/bNzFptRJvZOP+39F+4hWS92GY1pYOjfqgtydcRtlkBuoVylPl0bgqKcIB1Cdyug7G5HJmRzXSpuhl1/mgbJfKE68Glenc3eDFLESQDEwE1wPDaLaCjvP+d3HVVJZcQtIRzUzKpvt21ACG8A7lmw6j3felQaw9AJTWRUehklV0gwcM09HLYCs0Pgr07HCgyzWuMZxpdkiEARCJ7D6iY//vk40/wX5X3OtgMypJpXtqsGAtUsS+j6S22EGvR5Cm3gy9GZyxYdqCLfjsZ1BjRsBSDTAEgmU3DXQ58brhVQIDAQAB",
"permissions": [
"sidePanel",
"storage",
Expand Down
2 changes: 1 addition & 1 deletion extensions/dsh-browser/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "dsh-browser-extension",
"description": "Chrome and Firefox MV3 extension: sidebar chat with a local dsh instance and text-only read/operate of a user-controlled tab through the dsh browser bridge",
"version": "0.1.4",
"version": "0.1.5",
"author": "Yuxiang Lin",
"license": "MIT",
"private": true,
Expand Down
4 changes: 3 additions & 1 deletion packages/browser/bridge-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The **browser-operation bridge** for dsh: mounts a token-authenticated WebSocket
| Key | Type | Default | Description |
|---|---|---|---|
| `token` | `string` | generated | Fixed bearer token. When absent, a token is generated on first boot, persisted at `~/.dsh/ext-bridge-token` (chmod 0600), and printed in the boot log. |
| `trustedExtensionOrigins` | `string[]` | shipped extension id | Exact `chrome-extension://<id>` origins allowed to skip the token on loopback. The default pins the id derived from the extension manifest `key` (public — the pin stops incidental cross-extension access, not deliberate key-cloning impersonation); every other origin must present the token. `[]` disables the exemption (hardened mode: pair the token). Invalid entries fail startup. |
| `toolTimeoutMs` | `number` | 90000 | Per-tool-call budget, leaving time for the extension's 60-second approval window. |
| `snapshotMaxChars` | `number` | 32000 | Upper bound on one rendered snapshot's characters, minimum 500 (also negotiated to the extension via `hello.ok` caps). |
| `maxInteractiveItems` | `number` | 60 | Upper bound on interactive inventory items per snapshot. |
Expand Down Expand Up @@ -43,13 +44,14 @@ The workspace pins dsh 0.2.0-rc.2, the minimum supported runtime. Older DSH rele
npx @deepseek-ai/dsh@0.2.0-rc.2 web
```

The installer copies the unpacked extension to `~/.dsh/browser-extension` and opens `chrome://extensions`. Load that stable directory in Chrome and use the side panel. Loopback connections are discovered automatically and require no token entry; non-loopback deployments still require the configured bearer token.
The installer copies the unpacked extension to `~/.dsh/browser-extension` and opens `chrome://extensions`. Load that stable directory in Chrome and use the side panel. The shipped extension carries a stable manifest `key`, so its id — and therefore its token-exempt loopback access — stays constant across reinstalls and updates. Updating from a pre-0.1.5 build changes the id once (settings are stored per id): re-enter the bridge address and token in the panel if you had configured a remote bridge. Other installed extensions are not exempt and must present the configured bearer token; non-loopback deployments always require it.

## Security model

- 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.
- Zero-config loopback skips the token only for origins in `trustedExtensionOrigins` (default: the shipped extension's key-derived id). The manifest `key` is public, so this pins *incidental* access — an extension that deliberately copies the key inherits the id; set `trustedExtensionOrigins: []` and pair the token when hostile extensions are in scope. Origin checks bind browser contexts only — any local process can spoof an `Origin` header (and read the token file), so the bearer token remains the actual boundary. The exemption is loopback-only, but a reverse proxy terminating on 127.0.0.1 (`tailscale serve`, `ssh -L`, nginx) makes proxied clients appear as loopback — use hardened mode for such topologies.
- 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
4 changes: 3 additions & 1 deletion packages/browser/bridge-browser/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dsh 的**浏览器操作桥**:在宿主 webserver 上挂载一个 **token 认
| 键 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `token` | `string` | 自动生成 | 固定 bearer token。缺省时首次启动生成,写入 `~/.dsh/ext-bridge-token`(0600)并打印在启动日志。 |
| `trustedExtensionOrigins` | `string[]` | 随附扩展 id | 允许在回环免 token 的精确 `chrome-extension://<id>` Origin 列表。默认钉住由扩展 manifest `key` 派生的 id(key 是公开的——该钉住只防误连,不防刻意复制 key 的冒充);其它 Origin 必须携带 token。`[]` 表示完全关闭豁免(加固模式:强制 token 配对)。非法条目会导致启动失败。 |
| `toolTimeoutMs` | `number` | 90000 | 单次工具调用预算,为扩展的 60 秒审批窗口预留时间。 |
| `snapshotMaxChars` | `number` | 32000 | 单次快照渲染字符上限,最小为 500(经 `hello.ok` caps 协商给扩展)。 |
| `maxInteractiveItems` | `number` | 60 | 单次快照交互清单条数上限。 |
Expand Down Expand Up @@ -43,13 +44,14 @@ cd $HOME\.dsh\dsh-browser; pnpm start
npx @deepseek-ai/dsh@0.2.0-rc.2 web
```

安装器会把已解压扩展复制到 `~/.dsh/browser-extension` 并打开 `chrome://extensions`。在 Chrome 中加载这个稳定目录,然后使用侧边栏。扩展会自动发现回环连接,无需输入 token;非回环部署仍需要配置的 bearer token。
安装器会把已解压扩展复制到 `~/.dsh/browser-extension` 并打开 `chrome://extensions`。在 Chrome 中加载这个稳定目录,然后使用侧边栏。随附扩展带有固定 manifest `key`,其 id——以及随之的回环免 token 访问——在重装和升级后保持不变。从 0.1.5 之前的版本升级时 id 会变更一次(设置按 id 存储):如曾配置远程桥地址与 token,请在面板中重新填写。其它已安装扩展不在豁免之列,必须携带配置的 bearer token;非回环部署始终需要 token。

## 安全模型

- 桥路径在 `/api` 信任栅栏**之外**(栅栏只罩 client-connection 注册的路由),因此自带 bearer token 认证:首帧必须是 `hello`(5 秒内),常量时间比对,失败即断开。
- `/api` 载体钉在回环上的方法(`settings.*`、`credentials.*`、`host.pickDirectory`、`host.openPath`)对非回环来源**即使 token 正确也拒绝**——对 `--host 0.0.0.0` 部署的纵深防御。
- 同一时刻仅一个活动连接,新认证连接顶替旧连接。
- 零配置回环只对 `trustedExtensionOrigins` 中的 Origin 免 token(默认:随附扩展由 key 派生的 id)。manifest `key` 是公开的,因此这只*防*误连——刻意复制 key 的扩展会得到相同 id;需要抵御恶意扩展时请设置 `trustedExtensionOrigins: []` 并强制 token 配对。Origin 校验只约束浏览器上下文——任何本地进程都能伪造 `Origin` 头(也能读取 token 文件),真正的边界始终是 bearer token。豁免仅限回环,但在 127.0.0.1 上终结连接的反向代理(`tailscale serve`、`ssh -L`、nginx)会让代理客户端表现为回环——此类拓扑请使用加固模式。
- 桥是 confused-deputy 边界而非通用认证层:不要把 `dsh web --host 0.0.0.0` 暴露在不信任的网络上。
- 抽取的页面文字会标记为模型的不可信输入。页面读取遵循扩展的询问/自动/关闭策略;状态变更工具必须经过按 origin 的侧边栏决策,没有侧边栏时失败关闭。同源后续操作可只在当前侧栏会话中临时信任,永久信任仍需显式设置。

Expand Down
41 changes: 41 additions & 0 deletions packages/browser/bridge-browser/src/extension-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Trusted extension identity for the bridge's zero-config loopback mode.
*
* Chromium derives an unpacked extension's id from the manifest `key`
* (base64 DER SubjectPublicKeyInfo): the first 16 bytes of SHA-256 mapped
* onto `a`–`p`. Without a `key` the id is derived from the install path and
* differs on every machine — useless as an identity. The shipped extension
* therefore pins a stable `key` in `extensions/dsh-browser/manifest.json`,
* and the bridge pins the matching origin here. Forks and custom builds
* override the list through the `trustedExtensionOrigins` plugin config.
*
* @module @yuxianglin/dsh-bridge-browser/src/extension-origins
*/

import { createHash } from 'node:crypto'

/**
* Stable Chromium extension id of the shipped dsh-browser extension
* (SHA-256 over the manifest key's DER SPKI, first 16 bytes, hex→a-p).
*/
export const STABLE_CHROME_EXTENSION_ID = 'ampcoeplakeelcoengijbfbhjnlbdign'

/** Default loopback token exemption: exactly the shipped extension. */
export const DEFAULT_TRUSTED_EXTENSION_ORIGINS: readonly string[] = [
`chrome-extension://${STABLE_CHROME_EXTENSION_ID}`,
]

/**
* Derive the Chromium extension id from a manifest `key` value.
* @param key - base64 DER SubjectPublicKeyInfo from the manifest.
* @returns the 32-char a-p extension id, or undefined when the key is empty
* or does not decode to a DER SEQUENCE (lenient base64 decoding would
* otherwise mint a plausible-but-wrong id from truncated garbage).
*/
export function chromeExtensionIdFromKey(key: string): string | undefined {
const der = Buffer.from(key, 'base64')
// 0x30 = DER SEQUENCE tag; every SPKI starts with it.
if (der.length === 0 || der[0] !== 0x30) return undefined
const hex = createHash('sha256').update(der).digest('hex').slice(0, 32)
return [...hex].map((nibble) => 'abcdefghijklmnop'[Number.parseInt(nibble, 16)]).join('')
}
Loading
Loading