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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ node_modules/
.DS_Store
*.log
npm-debug.log*

# pnpm dependency resolution is not pinned for this plugin
pnpm-lock.yaml
114 changes: 113 additions & 1 deletion README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,77 @@ execution waits for user confirmation, plus a settings section to manage channel
| Channel | Location | Notes |
| --- | --- | --- |
| Browser notification (in-page banner) + optional OS notification | Client | Two independent settings. **Browser notification** shows a text banner in the top-right while the page is visible. **OS notification** uses the browser Notification API, so it also fires when the tab is in the background or the window is minimized (browser notification permission required). Keep the browser running and use the host system channel if you leave the page entirely. |
| System notification | Host | macOS `osascript` / Linux `notify-send` / Windows PowerShell native toast (Windows 10/11 action center); works even when the browser is closed. Optional system sound (macOS `afplay` / Windows built-in alert sound). |
| System notification | Host | macOS `osascript` / Linux `notify-send` / Windows PowerShell native toast (Windows 10/11 action center); works even when the browser is closed. Optional system sound (macOS `afplay` / Windows built-in alert sound). Can be pointed at any notifier command via [`system.notifier`](#custom-notifier-systemnotifier). |
| Feishu group bot | Host | Text message, optional signed secret (timestamp + HMAC-SHA256), optional custom message template. |
| DingTalk group bot | Host | Text message, optional signed secret (timestamp + sign), optional custom message template. |
| WeCom group bot | Host | Text message, optional custom message template. |
| Generic webhook | Host | Custom URL + headers + JSON/text template. Placeholders: `{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`. Works with Slack / Discord / ntfy / Bark / ServerChan / PushPlus, etc. |

## Custom notifier (`system.notifier`)

The host channel calls the operating system's own notification command by default. In some
environments that **fails silently** — most notably on macOS, where the identity behind
`osascript display notification` is taken from the host app up the launch chain. If that app
never asked for notification permission, macOS drops the notification while `osascript` still
exits `0`, so the plugin has no way to notice.

`system.notifier` is the escape hatch: set `command` and it is used instead, with `{{title}}` /
`{{body}}` interpolated into `args` (the same template shape the webhook channels use; unknown
tokens are left intact).

**The plugin makes no assumption about the notifier and ships no default argument template** —
`command` is run as given and `args` is its whole argv. Flags are tool-specific, so they must
match whichever tool you point at:

```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
- id: dsh-plugin-notify
config:
system:
# terminal-notifier (brew install terminal-notifier)
notifier:
command: /opt/homebrew/bin/terminal-notifier
args: ["-title", "{{title}}", "-message", "{{body}}"]
```

The next two replace the same `notifier:` block:

```yaml
# alerter (without --timeout it waits forever)
notifier:
command: /Users/you/.local/bin/alerter
args: ["--title", "{{title}}", "--message", "{{body}}", "--timeout", "30"]
```

```yaml
# a self-compiled app bundle (UNUserNotificationCenter, own bundle id and icon)
notifier:
command: /Users/you/Applications/DSH Notifier.app/Contents/MacOS/notifier
args: ["-title", "{{title}}", "-message", "{{body}}"]
```

⚠️ `command` **must be an absolute path**: the plugin runs it through `execFile`, with no shell
in between, so `~` is not expanded (`~/.local/bin/alerter` just yields `ENOENT`). The same goes
for environment variables such as `$HOME`.

Two things worth knowing when choosing:

- **terminal-notifier removed `-sender` in 3.0.0**, because it moved to `UserNotifications`,
which reads the real signed identity and allows no override. It therefore appears under its
own name in System Settings → Notifications and needs one authorisation.
- **alerter still uses the older `NSUserNotification`, so `--sender` still works** to impersonate
an already-authorised bundle id. Where the host terminal never asked for notification
permission and you would rather not grant a new one, that is the way to get notifications
immediately; the cost is that they appear under the impersonated app's name and icon. A
self-compiled app bundle is the cleaner option if one authorisation is acceptable.

An empty `command` keeps the OS default (and is the default value), so behaviour is unchanged
unless you opt in; `args: []` means "run it with no arguments" and is a valid configuration.

Unlike the OS default commands, **a real notifier reports real exit codes** (terminal-notifier
uses `3` for "not authorized" and `4` when it cannot reach the notification service), so a failed
delivery surfaces as a warning in the plugin log instead of a silent success.

## Message format

All webhook channels share the same placeholders:
Expand Down Expand Up @@ -131,6 +196,46 @@ string on write means "keep unchanged", and `clearSecrets` lists paths to clear.
generic request headers are stored in plain text, so do not put sensitive credentials there
(other than the Feishu/DingTalk signature secrets).

### API access control

All three `/dsh-plugin-notify/*` routes sit behind a **trust fence** that 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 lands on this server.
- **Cross-site requests** — a malicious page writing to the local API directly.

This API is a *write* surface (`system.notifier.command` is executed), so neither path is
acceptable. The fence binds browser and non-browser clients alike: over plain HTTP a browser may
send neither `Origin` nor Fetch metadata, so `Host` is the only always-reliable signal.

Loopback (`localhost` / `::1` / `127.x.x.x`) is trusted by default. When the deployment is served
over a LAN, a tunnel or a reverse proxy, add its authority (exact `host:port`) to
`security.trustedHosts`:

```yaml
- id: dsh-plugin-notify
config:
security:
trustedHosts: ["dsh.example:3443"]
```

Unset, the behaviour is loopback-only. DSH applies the same design to its `/api` bridge
(`isTrustedApiRequest` in `dsh-client-connection`), but routes registered through
`webServer.register` do **not** inherit it, so the plugin carries its own.

**A gateway that rewrites Host / Origin needs no configuration at all.** `dsh-mobile`, for
example, authenticates the caller on the LAN side and then forwards as the upstream:

```js
headers.host = upstream.host; // 127.0.0.1:3080
headers.origin = upstream.origin; // http://127.0.0.1:3080
headers["sec-fetch-site"] = "same-origin";
```

The plugin therefore receives a clean loopback same-origin request and the fence allows it. Only
a reverse proxy that **passes the external Host through unchanged** (nginx and Caddy do by
default, as does a bare tunnel) needs its authority listed in `security.trustedHosts`.

## Development

```sh
Expand Down Expand Up @@ -184,6 +289,13 @@ Endpoints:
enabled, the browser Notification API can notify while the tab is in the background or the
window is minimized (permission required, and the browser must stay running). Use the host
system notification channel when the browser is fully closed.
- Host system notifications depend on the notification identity of the process running
`dsh web`. On macOS that identity is inherited from the host app up the launch chain
(terminal / launcher): if it never asked for notification permission (Ghostty, for example,
defaults `app-notifications` to `never`), or `dsh web` is started by launchd with no GUI
session, `osascript` is dropped silently and still exits `0`. Point
[`system.notifier`](#custom-notifier-systemnotifier) at a notifier that carries its own
identity instead.
- Feishu/DingTalk signature secrets are write-only + read-sanitized and stored unencrypted in
the local `settings.yaml` (or the fallback `config.json`); do not put other sensitive
credentials in generic-webhook URLs or request headers.
Expand Down
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,58 @@ DeepSeek Harness Web GUI 的消息提醒插件:任务回合执行结束、或
| 渠道 | 位置 | 说明 |
| --- | --- | --- |
| 浏览器通知(页面内横幅)+ 系统原生通知(可选) | 客户端 | 两个独立设置项:「浏览器通知」在页面可见时于右上角弹出文字横幅;「系统原生通知」通过浏览器 Notification API 弹出操作系统通知,标签页在后台/最小化时也能收到(需浏览器通知权限);完全离开页面时请配合系统通知使用 |
| 系统通知 | 宿主机 | macOS `osascript` / Linux `notify-send` / Windows PowerShell 原生 toast(Windows 10/11 操作中心),浏览器关闭也能收到;可选系统提示音(macOS `afplay` / Windows 系统内置提示音) |
| 系统通知 | 宿主机 | macOS `osascript` / Linux `notify-send` / Windows PowerShell 原生 toast(Windows 10/11 操作中心),浏览器关闭也能收到;可选系统提示音(macOS `afplay` / Windows 系统内置提示音);可用 [`system.notifier`](#自定义通知器systemnotifier) 换成任意通知器命令 |
| 飞书群机器人 | 宿主机 | 文本消息,可选签名密钥(timestamp + HMAC-SHA256),可选自定义消息模板 |
| 钉钉群机器人 | 宿主机 | 文本消息,可选加签(timestamp + sign),可选自定义消息模板 |
| 企业微信群机器人 | 宿主机 | 文本消息,可选自定义消息模板 |
| 通用 Webhook | 宿主机 | 自定义 URL + headers + JSON/文本模板,占位符 `{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`,可对接 Slack / Discord / ntfy / Bark / Server酱 / PushPlus 等 |

## 自定义通知器(`system.notifier`)

宿主机通道默认调用操作系统自带的通知命令。但在某些环境里这会**静默失效**——最典型的是 macOS:`osascript display notification` 的通知身份取自启动链上的宿主 App,如果那个 App 从未申请过通知权限,系统会把通知丢掉,而 `osascript` 仍然以 `0` 退出,所以插件无从察觉。

`system.notifier` 是逃生舱:填了 `command` 就改用它,`args` 里可用 `{{title}}` / `{{body}}` 占位符(与 Webhook 模板同一套写法;未识别的 token 原样保留)。

**插件不对通知器做任何假设,也没有默认参数模板**——`command` 指向什么就执行什么,`args` 就是它的完整 argv。参数是工具专属的,所以必须照你所选工具的用法写:

```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
- id: dsh-plugin-notify
config:
system:
# terminal-notifier(brew install terminal-notifier)
notifier:
command: /opt/homebrew/bin/terminal-notifier
args: ["-title", "{{title}}", "-message", "{{body}}"]
```

下面两个是同一个 `notifier:` 块的替换内容:

```yaml
# alerter(无参数则永久等待,记得给 --timeout)
notifier:
command: /Users/you/.local/bin/alerter
args: ["--title", "{{title}}", "--message", "{{body}}", "--timeout", "30"]
```

```yaml
# 自编译的 app bundle(UNUserNotificationCenter + 自己的 bundle id 与图标)
notifier:
command: /Users/you/Applications/DSH Notifier.app/Contents/MacOS/notifier
args: ["-title", "{{title}}", "-message", "{{body}}"]
```

⚠️ `command` **必须是绝对路径**:插件用 `execFile` 直接执行,不经过 shell,所以 `~` 不会被展开(写 `~/.local/bin/alerter` 只会得到 `ENOENT`)。`$HOME` 之类的环境变量同理。

选型上的两点提醒:

- **`terminal-notifier` 3.0.0 起移除了 `-sender`**,因为它改用了 `UserNotifications`,而该框架读取真实签名身份、不允许覆盖。所以它会以自己的名义出现在「系统设置 → 通知」里,首次使用需要授权一次。
- **`alerter` 仍走旧的 `NSUserNotification`,因此仍支持 `--sender` 冒充一个已授权的 bundle id**。在宿主终端从未申请过通知权限、又不想新增授权的环境里,这是能立刻出通知的办法;代价是通知显示的是被冒充 App 的名字与图标。不介意多授权一次的话,自编译 app bundle 是更干净的选择。

`command` 留空即维持原有的系统默认行为(也是默认值),因此不配置时行为完全不变;`args: []` 表示"不带参数执行",是合法配置。

相比系统默认命令,**正经的通知器带有真实退出码**(terminal-notifier 用 `3` 表示未授权、`4` 表示拿不到通知服务),发送失败会真正冒泡到插件日志里的告警,而不是静默报成功。

## 消息格式

所有 Webhook 渠道共用同一套占位符:`{{title}} {{body}} {{kind}} {{sessionId}} {{turn}} {{toolName}} {{reason}} {{time}}`。
Expand Down Expand Up @@ -105,6 +151,36 @@ corepack pnpm remove dsh-plugin-notify # 或 dsh plugin --profile web remove d

飞书/钉钉签名密钥按 schema 声明为 `role('secret')` 只写字段:设置文档与所有 wire 面都看不到明文,只暴露「是否已配置」标记;接口读回空串并用 `secretSet` 标记,写入时空串表示保持不变,`clearSecrets` 列出要清除的路径。Webhook 地址与通用请求头以明文保存,请勿在其中放置敏感凭据(除飞书/钉钉签名密钥外)。

### 接口访问控制

三条 `/dsh-plugin-notify/*` 路由都带一道**信任栅栏**,守护的是浏览器对本地 HTTP API 打开的两条「混淆代理」路径:

- **DNS rebinding** —— `Host` 指向攻击者域名,而连接实际落到本机;
- **跨站请求** —— 恶意页面直接向本地 API 发起的写入。

这条 API 是**写入面**(`system.notifier.command` 会被执行),所以两条路径都必须堵死。栅栏同时约束浏览器与非浏览器客户端:纯 HTTP 下浏览器可能既不发送 `Origin` 也不发送 Fetch 元数据,因此 `Host` 是唯一始终可靠的依据。

默认只信任回环地址(`localhost` / `::1` / `127.x.x.x`)。通过局域网、隧道或反向代理对外提供访问时,把对外的 authority(精确的 `host:port`)加进 `security.trustedHosts`:

```yaml
- id: dsh-plugin-notify
config:
security:
trustedHosts: ["dsh.example:3443"]
```

不配时行为等同只信任回环。DSH 自身对 `/api` 桥有同一套设计(`dsh-client-connection` 的 `isTrustedApiRequest`),但**通过 `webServer.register` 注册的插件路由不会继承它**,所以插件需要自带。

**转发时重写 Host / Origin 的网关不需要任何配置。** 例如 `dsh-mobile` 在 LAN 侧完成认证后,会以**上游身份**转发请求:

```js
headers.host = upstream.host; // 127.0.0.1:3080
headers.origin = upstream.origin; // http://127.0.0.1:3080
headers["sec-fetch-site"] = "same-origin";
```

于是插件收到的是一个干净的环回同源请求,栅栏直接放行。只有**原样透传外部 Host** 的反向代理(nginx / Caddy 的默认行为,或裸隧道)才需要把对外 authority 加进 `security.trustedHosts`。

## 开发

```sh
Expand Down Expand Up @@ -133,6 +209,7 @@ node --test
## 已知限制

- 浏览器渠道默认是页面内文字横幅;开启「系统原生通知」后,标签页在后台或窗口最小化时也会通过浏览器 Notification API 弹出系统通知(需要浏览器通知权限,且浏览器必须保持运行)。浏览器完全关闭时请使用宿主机系统通知渠道。
- 宿主机系统通知依赖运行 `dsh web` 的进程在操作系统里的通知身份。macOS 上这个身份取自启动链上的宿主 App(终端 / 启动器):如果它从未申请过通知权限(例如 Ghostty 的 `app-notifications` 默认是 `never`),或者 `dsh web` 由 launchd 启动而没有 GUI 会话,`osascript` 会被系统静默丢弃且仍以 `0` 退出。遇到这种情况请用上面的 [`system.notifier`](#自定义通知器systemnotifier) 指向一个自带身份的通知器。
- 飞书/钉钉签名密钥仅做「只写 + 读回脱敏」,保存在本地 `settings.yaml`(或回退 `config.json`)中但未加密;请勿在通用 Webhook 的地址或请求头中放置其他敏感凭据。

## License
Expand Down
Loading