dsh-computers 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PROTOCOL.md +559 -0
- package/README.md +147 -2
- package/agent/README.md +276 -0
- package/agent/dsh-computers-agent.mjs +1993 -0
- package/client.js +3219 -0
- package/cordis.patch.yml +23 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +211 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +381 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +697 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +181 -0
- package/lib/tools.js +394 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-computers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把你自己电脑(Mac / Windows / Linux)接到一个 **DSH Web** 部署上,让部署里的智能体在这些电脑上执行 shell 命令。
|
|
4
|
+
|
|
5
|
+
形态是「**一台服务器作为唯一主控,其余电脑作为被控端接进来**」:被控端只向外发起一条长连接,主控端负责注册表、在线状态和命令派发。
|
|
6
|
+
|
|
7
|
+
- **每台电脑都装同一个插件**,所以不需要在终端里粘命令:桌面端 DSH 打开 设置 → 电脑 → **本机运行时**,填服务器地址 + 加入密钥,点「连接」即可。
|
|
8
|
+
- 服务器那一侧是 **电脑** 列表:在线状态、重命名、删除,以及 `+ 连接电脑` 生成的兜底命令(给不装 DSH 的机器,比如跑 launchd/systemd 的 Mac mini、Linux 服务器)。
|
|
9
|
+
- 对话里智能体用 `computer_exec` 把命令派到指定电脑上跑,用 `computer_list` 看有哪些机器。
|
|
10
|
+
|
|
11
|
+
**没有内网穿透、没有开放端口**:被控端只向外发起一条 SSE 长连接,所以两台 Mac 在 NAT/家用网络后面也能连上。拓扑明确是 hub-and-spoke,**不支持**电脑之间互相控制,也不做 P2P。
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
┌──────────── 服务器(dsh web,role: hub)────────────┐
|
|
15
|
+
│ dsh-computers 插件 │
|
|
16
|
+
│ · 电脑注册表 + SSE hub + 命令派发 │ ◀──SSE──── ┌─ 这台 Mac(桌面端 DSH + 插件)─┐
|
|
17
|
+
│ · computer_list / computer_exec 工具 │ ──命令──▶ │ 设置 → 电脑 → 本机运行时 │
|
|
18
|
+
│ · 设置 → 电脑 列表 + 连接电脑 │ ◀─结果─── │ 连接后即成为被控端 │
|
|
19
|
+
└─────────────────────────────────────────────────────┘ └───────────────────────────────┘
|
|
20
|
+
▲ 浏览器(手机/笔记本都能控制全部电脑) ◀──SSE──── ┌─ 另一台 Mac / Linux ──────────┐
|
|
21
|
+
│ │ 同样装插件,或跑 agent 兜底 │
|
|
22
|
+
└─────────────────────────────────────────────────────────┴───────────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 1. 服务器:装插件(主控)
|
|
26
|
+
|
|
27
|
+
插件是纯 JavaScript,无构建、无第三方依赖,直接从 GitHub 装:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 方式 A:CLI(推荐用分支,不用 tag,见下面「更新」)
|
|
31
|
+
dsh plugin install github:9Ashwin/dsh-computers
|
|
32
|
+
|
|
33
|
+
# 方式 B:DSH 设置 → 插件市场 → 安装,粘贴
|
|
34
|
+
# https://github.com/9Ashwin/dsh-computers
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 更新
|
|
38
|
+
|
|
39
|
+
GitHub 安装的插件在 profile 里是**按 spec 固定**的(`github:user/repo` 或 `github:user/repo#v1.0.2`,lockfile 会钉到具体 commit),所以:
|
|
40
|
+
|
|
41
|
+
- **装 tag(`#v1.0.2`)= 冻在那个版本**,插件市场的「检查更新」永远发现不了新版本。
|
|
42
|
+
- **装分支(不带 `#`)= 可更新**:插件市场 → 已安装 → 「检查更新」会重新解析分支 HEAD 并自动重装,不需要手动卸载。
|
|
43
|
+
- 手动更新时**必须换 spec 才会生效**:用同样的 spec 再装一次只会得到 `already-installed`(这就是"再安装没法更新"的原因)。要升级到某个新版本,直接装新 tag:
|
|
44
|
+
```bash
|
|
45
|
+
dsh plugin install github:9Ashwin/dsh-computers#v1.0.2
|
|
46
|
+
```
|
|
47
|
+
或者先移除再装;种 spec 变了才会真正重新拉取。
|
|
48
|
+
|
|
49
|
+
装好后重启 `dsh web`,设置里会出现 **电脑** 页。
|
|
50
|
+
|
|
51
|
+
服务端建议把 `role` 固定成 `hub`(只当主控)。**对外地址会自动识别**:你从哪个域名打开设置页(含反向代理的 `X-Forwarded-Proto/Host`、`Forwarded` 头),生成的命令就用哪个地址,通常不用配。需要强制指定时才写 `publicUrl`:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
# ~/.dsh/profiles/<profile>/cordis.patch.yml
|
|
55
|
+
- id: dsh-computers
|
|
56
|
+
name: dsh-computers
|
|
57
|
+
config:
|
|
58
|
+
role: hub
|
|
59
|
+
# 留空 = 按浏览器请求自动推导(含反代的 X-Forwarded-* / Forwarded);
|
|
60
|
+
# 只有不想信任代理时才显式写死,例如 https://dsh.example.com
|
|
61
|
+
publicUrl: ''
|
|
62
|
+
execEnabled: true # false 时拒绝所有远程执行
|
|
63
|
+
execTimeoutMs: 120000 # 单条命令默认超时
|
|
64
|
+
keyTtlMinutes: 15 # 加入密钥有效期
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> **安全前提**:请开启 DSH 的浏览器登录/可信来源(`dsh web` 默认就有),不要把裸端口暴露给公网。插件给"能使用这个 DSH 的智能体"的权限,等于能在这台电脑上执行任意命令。
|
|
68
|
+
|
|
69
|
+
## 2. 每台电脑:装同一个插件(被控)
|
|
70
|
+
|
|
71
|
+
1. 按上一节的方式在**这台电脑**的 DSH 里装上 `dsh-computers`(桌面端 / 命令行装法相同)。
|
|
72
|
+
2. 打开 DSH → 设置 → 电脑,在 **本机运行时** 卡片里填:
|
|
73
|
+
- **服务器地址**:你服务器的对外地址,如 `https://dsh.example.com`
|
|
74
|
+
- **加入密钥**:在服务器的 设置 → 电脑 → `+ 连接电脑` 里复制的 `dshc_…`
|
|
75
|
+
3. 点 **连接**,卡片变成「运行中」,服务器列表里随即出现这台电脑(在线)。
|
|
76
|
+
|
|
77
|
+
被控端默认 `role: both`,所以这台电脑自己也能当主控;只想当被控端就设 `role: runtime`。
|
|
78
|
+
|
|
79
|
+
运行时的开关是持久的:**DSH 一启动就自动连回服务器**,不用每次手动点。关掉 DSH 就离线。
|
|
80
|
+
|
|
81
|
+
### 兜底:不装 DSH 的机器
|
|
82
|
+
|
|
83
|
+
Mac mini 常驻、Linux 服务器这类不跑桌面的机器,用服务器生成的命令(launchd / systemd 常驻):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
curl -fsSL 'https://<你的服务器>/api/dsh-computers/join.sh?key=dshc_xxx' | sh
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
它会下载 agent 到 `~/.dsh-computers/`、注册系统服务并启动。手动管理:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
node ~/.dsh-computers/agent.mjs status
|
|
93
|
+
node ~/.dsh-computers/agent.mjs service restart
|
|
94
|
+
node ~/.dsh-computers/agent.mjs service uninstall
|
|
95
|
+
tail -f ~/.dsh-computers/agent.log
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 3. 对话里怎么用
|
|
99
|
+
|
|
100
|
+
智能体会看到两个工具:
|
|
101
|
+
|
|
102
|
+
- `computer_list` —— 列出已连接电脑(名字、平台、在线状态)
|
|
103
|
+
- `computer_exec` —— 在指定电脑上执行命令:`{"computer": "my-mac-mini.local", "command": "sysctl -n hw.memsize"}`
|
|
104
|
+
|
|
105
|
+
系统提示里会自动附上当前电脑清单,所以直接说「看看两台 mac 的内存」它就会分别调用。
|
|
106
|
+
|
|
107
|
+
## 4. 端点一览
|
|
108
|
+
|
|
109
|
+
| 方法 | 路径 | 说明 |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| POST | `/api/dsh-computers/agent/enroll` | 加入密钥换取电脑 token(runtime 卡片也走这里) |
|
|
112
|
+
| GET | `/api/dsh-computers/agent/events` | 被控端的 SSE 长连接(Bearer token) |
|
|
113
|
+
| POST | `/api/dsh-computers/agent/results` | 命令执行结果回传 |
|
|
114
|
+
| POST | `/api/dsh-computers/agent/heartbeat` | 心跳 |
|
|
115
|
+
| GET | `/api/dsh-computers/state` | 设置页数据(角色、电脑列表,需要浏览器会话) |
|
|
116
|
+
| POST | `/api/dsh-computers/keys` | 生成一次性加入密钥 |
|
|
117
|
+
| GET | `/api/dsh-computers/key-status` | 弹窗轮询用 |
|
|
118
|
+
| POST | `/api/dsh-computers/delete` / `rename` | 删除 / 重命名电脑 |
|
|
119
|
+
| GET | `/api/dsh-computers/runtime` | 本机运行时状态 |
|
|
120
|
+
| POST | `/api/dsh-computers/runtime/connect` / `enable` / `disconnect` | 连接 / 启用开关 / 断开本机运行时 |
|
|
121
|
+
| GET | `/api/dsh-computers/join.sh` | 兜底安装脚本 |
|
|
122
|
+
| GET | `/api/dsh-computers/agent.mjs` | agent 本体(兜底脚本下载) |
|
|
123
|
+
| GET | `/api/dsh-computers/health` | 健康检查 |
|
|
124
|
+
|
|
125
|
+
浏览器端点都先过 DSH 的请求闸门(`connection.requestRejection`),未登录/不可信来源直接拒绝;被控端端点用 Bearer token 认证(服务端只存 `sha256(token)`)。
|
|
126
|
+
|
|
127
|
+
## 5. 开发与验证
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
node scripts/host-smoke.mjs # Host 半:真实 HTTP 监听 + 假 agent/hub,覆盖注册/SSE/exec/超时/runtime
|
|
131
|
+
node --test agent/test/ # agent 半:enroll / SSE / exec / 超时 / 截断 / 内嵌运行时
|
|
132
|
+
node --check client.js # 浏览器半
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
协议细节(所有路由、SSE 事件、状态文件、工具 schema、runtime 契约)见 [PROTOCOL.md](./PROTOCOL.md)。
|
|
136
|
+
|
|
137
|
+
## 6. 已知限制
|
|
138
|
+
|
|
139
|
+
- 一次执行返回一次性结果(不流式),长命令靠 `timeoutMs`;输出单流上限 1 MiB。
|
|
140
|
+
- 一台电脑同一时刻只保留一条连接(新的会顶掉旧的)。
|
|
141
|
+
- 在线判定只看 SSE 是否存活:杀掉进程立刻离线;网络静默(无 FIN)最多差一个心跳周期。
|
|
142
|
+
- Windows 被控端目前可用 `node agent.mjs run` 手动运行,服务安装只覆盖 macOS/Linux。
|
|
143
|
+
- 电脑与 Bot 的绑定还没有(列表里的 `1/1 Bot` 是占位徽标);v1 里所有电脑对智能体都可选。
|
|
144
|
+
- 不做 P2P / 电脑互控;要隔离环境请用 VPN,或把某台桌面端当 hub、另一台连过去(仍是单向)。
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
package/agent/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# dsh-computers-agent
|
|
2
|
+
|
|
3
|
+
The machine-side half of **dsh-computers**: a single, dependency-free Node.js
|
|
4
|
+
script that runs on your own Mac/Linux box, dials **out** to a DSH Web server,
|
|
5
|
+
and executes shell commands the server's agent asks for. It is the "cloud
|
|
6
|
+
computer" client — no inbound port, no NAT/firewall change, no daemon port.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
dsh web ──SSE (outbound from the Mac)──▶ dsh-computers-agent ──▶ /bin/sh -lc
|
|
10
|
+
│
|
|
11
|
+
├─ ~/.dsh-computers/config.json (0600, token)
|
|
12
|
+
├─ ~/.dsh-computers/agent.log (5 MiB rotate)
|
|
13
|
+
└─ launchd / systemd user service
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The same file is used two ways, with one implementation:
|
|
17
|
+
|
|
18
|
+
- **CLI / service** (headless, always-on): `enroll` + `run` + `launchd`/`systemd`
|
|
19
|
+
from the installer one-liner.
|
|
20
|
+
- **Embedded runtime** (desktop DSH app): the plugin's Settings → 电脑 →
|
|
21
|
+
*本机运行时* card calls the module's exported `startRuntime()` in-process, so
|
|
22
|
+
the user never opens a terminal (PROTOCOL §10.4).
|
|
23
|
+
|
|
24
|
+
The file is served verbatim by the host plugin at
|
|
25
|
+
`/api/dsh-computers/agent.mjs`, so what you run is exactly what the repository
|
|
26
|
+
contains. It imports only Node built-ins, never opens a listening socket, and
|
|
27
|
+
never prints the token.
|
|
28
|
+
|
|
29
|
+
The wire contract lives in [`../PROTOCOL.md`](../PROTOCOL.md) (§3 and §6).
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Node.js **22 or newer** (`node -v`)
|
|
34
|
+
- macOS (launchd), Linux (systemd `--user`) or Windows (manual, v1)
|
|
35
|
+
- Outbound HTTP(S) to the dsh-web server
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
### Recommended: the one-liner from Settings → 电脑
|
|
40
|
+
|
|
41
|
+
The server's **Connect computer** dialog generates a command like:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
curl -fsSL 'http://<server>/api/dsh-computers/join.sh?key=dshc_…' | sh
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It downloads this script to `~/.dsh-computers/agent.mjs`, enrolls with the
|
|
48
|
+
one-time key, installs the login service, and starts it.
|
|
49
|
+
|
|
50
|
+
### Manual
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
mkdir -p ~/.dsh-computers
|
|
54
|
+
curl -fsSL 'http://<server>/api/dsh-computers/agent.mjs' -o ~/.dsh-computers/agent.mjs
|
|
55
|
+
node ~/.dsh-computers/agent.mjs enroll --server http://<server> --key dshc_…
|
|
56
|
+
node ~/.dsh-computers/agent.mjs service install
|
|
57
|
+
node ~/.dsh-computers/agent.mjs service start
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Enrollment keys are single use and expire (15 minutes by default). When the
|
|
61
|
+
server says the machine is already connected, re-run with `--replace`:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
node ~/.dsh-computers/agent.mjs enroll --server http://<server> --key dshc_… --replace
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> A successful `enroll` prints `✓ 已连接 <server>,这台电脑现在是 <name>`.
|
|
68
|
+
> If the stream probe fails, the credentials are still saved and `run` retries.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
|
|
74
|
+
dsh-computers-agent run
|
|
75
|
+
dsh-computers-agent service install|uninstall|start|stop|restart|status
|
|
76
|
+
dsh-computers-agent status
|
|
77
|
+
dsh-computers-agent --version | --help
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Command | What it does |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `enroll` | POSTs the join key to `/api/dsh-computers/agent/enroll`, saves `{serverUrl, computerId, token, name, enrolledAt}` to `config.json` (atomic, `0600`), then opens the SSE stream for ~5 s to prove the round trip. Exits non-zero with the server's message on failure. |
|
|
83
|
+
| `run` | Foreground worker: one SSE stream to `/api/dsh-computers/agent/events`; executes `exec` events, kills the process group on `cancel`, POSTs results, sends heartbeats, reconnects with jittered 1 s → 30 s backoff. |
|
|
84
|
+
| `status` | Prints server, computer id/name, redacted token (last 6 chars only), config/log paths and service state. Exit `0` when enrolled, `1` when not. |
|
|
85
|
+
| `service` | Login/auto-restart service. See below. |
|
|
86
|
+
|
|
87
|
+
Ctrl-C / `SIGTERM` makes `run` exit cleanly (in-flight commands are killed).
|
|
88
|
+
|
|
89
|
+
## The service
|
|
90
|
+
|
|
91
|
+
**macOS (launchd).** `service install` writes
|
|
92
|
+
`~/Library/LaunchAgents/com.dsh.computers.agent.plist` with
|
|
93
|
+
`ProgramArguments = [node, <absolute agent path>, run]`, `RunAtLoad`,
|
|
94
|
+
`KeepAlive`, and `StandardOutPath`/`StandardErrorPath` pointing at
|
|
95
|
+
`~/.dsh-computers/agent.log`. `service start` runs
|
|
96
|
+
`launchctl bootstrap gui/$(id -u) <plist>` and falls back to `launchctl load -w`.
|
|
97
|
+
`stop` uses `launchctl bootout` (fallback `unload -w`). `stop` and `uninstall`
|
|
98
|
+
are idempotent: when nothing is loaded they still exit `0`.
|
|
99
|
+
|
|
100
|
+
**Linux (systemd `--user`).** `service install` writes
|
|
101
|
+
`~/.config/systemd/user/dsh-computers-agent.service`, runs
|
|
102
|
+
`systemctl --user daemon-reload`; `start` runs
|
|
103
|
+
`systemctl --user enable --now ...`; `stop` runs `disable --now`. Without a
|
|
104
|
+
usable `systemctl --user`, `start`/`stop` fall back to `nohup … run &` with a
|
|
105
|
+
pid file (`~/.dsh-computers/agent.pid`).
|
|
106
|
+
|
|
107
|
+
**Windows.** v1 prints the manual command and the Task Scheduler setup and exits
|
|
108
|
+
`0` (no service automation yet).
|
|
109
|
+
|
|
110
|
+
## Embedded runtime mode (`startRuntime`)
|
|
111
|
+
|
|
112
|
+
When the plugin runs inside the user's **desktop** DSH, `lib/runtime.js` imports
|
|
113
|
+
this same file and starts an in-process runtime — no CLI, no service, no
|
|
114
|
+
`~/.dsh-computers` config:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { startRuntime } from '../agent/dsh-computers-agent.mjs'
|
|
118
|
+
|
|
119
|
+
const runtime = startRuntime({
|
|
120
|
+
serverUrl: 'https://dsh.example.com',
|
|
121
|
+
token: 'dshc_…', // from the hub's enroll response
|
|
122
|
+
computerId: 'cmp_…',
|
|
123
|
+
name: 'Desk Mac mini', // optional display name
|
|
124
|
+
heartbeatMs: 20000, // optional; `hello` overrides it
|
|
125
|
+
log: (level, message) => {}, // 'info' | 'warn' | 'error'
|
|
126
|
+
onState: (state) => {}, // connect/disconnect/error/stop transitions
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
runtime.status() // synchronous snapshot (see below)
|
|
130
|
+
await runtime.stop() // idempotent: aborts SSE, SIGKILLs children, awaits the loop
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`status()` returns
|
|
134
|
+
`{ running, connected, serverUrl, computerId, name, heartbeatMs, since, lastConnectedAt, lastError, attempts, exitCode }`
|
|
135
|
+
(`since`/`lastConnectedAt` are ISO strings; `lastError` is `null` when healthy).
|
|
136
|
+
`onState` receives the same shape and is de-duplicated to real transitions.
|
|
137
|
+
|
|
138
|
+
Semantics guaranteed to the host:
|
|
139
|
+
|
|
140
|
+
- `startRuntime()` returns immediately; the SSE loop/backoff runs in the
|
|
141
|
+
background. The module is **import-safe**: importing it never runs CLI code,
|
|
142
|
+
touches the filesystem, prints, or starts child processes (the CLI main only
|
|
143
|
+
runs when this file is the process entry point, via `process.argv[1]` +
|
|
144
|
+
`pathToFileURL`, handling relative and symlinked paths).
|
|
145
|
+
- `stop()` aborts the SSE fetch, retains no timers, sends `SIGKILL` to every
|
|
146
|
+
in-flight command's process group and **waits** for those children to be
|
|
147
|
+
reaped before resolving. It is safe to call twice (and from inside `onState`).
|
|
148
|
+
- A fatal `{"error":"invalid-token"}` 401 never throws: the loop ends,
|
|
149
|
+
`status()` reports `lastError: 'invalid-token'` and `exitCode: 1`, and
|
|
150
|
+
`onState` fires so the host can surface it. Any other 401/403/404/5xx is
|
|
151
|
+
treated as a transient server condition and retried with backoff.
|
|
152
|
+
- The embedded runtime never writes `~/.dsh-computers`; the host persists its
|
|
153
|
+
own `runtime.json` (PROTOCOL §10.2) and the CLI remains the only writer of
|
|
154
|
+
`config.json`.
|
|
155
|
+
|
|
156
|
+
`AGENT_VERSION` is exported too (the host can put it in the §3.1 enroll body).
|
|
157
|
+
|
|
158
|
+
## Files and environment
|
|
159
|
+
|
|
160
|
+
| Path | Purpose |
|
|
161
|
+
| --- | --- |
|
|
162
|
+
| `${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers}/config.json` | Credentials, mode `0600`, atomic tmp+rename |
|
|
163
|
+
| `${DSH_COMPUTERS_HOME}/agent.log` | Append-only log, rotates at 5 MiB (keeps one `agent.log.1`) |
|
|
164
|
+
| `~/Library/LaunchAgents/com.dsh.computers.agent.plist` | macOS service unit |
|
|
165
|
+
| `~/.config/systemd/user/dsh-computers-agent.service` | Linux service unit |
|
|
166
|
+
| `~/.dsh-computers/agent.pid` | Only used by the `nohup` fallback |
|
|
167
|
+
|
|
168
|
+
Environment variables:
|
|
169
|
+
|
|
170
|
+
- `DSH_COMPUTERS_HOME` — relocate config + log (useful for tests and for
|
|
171
|
+
multiple installs). Also written into the service unit so the service reads
|
|
172
|
+
the same credentials.
|
|
173
|
+
- `DSH_COMPUTERS_SERVICE_LABEL` — override the launchd/systemd label
|
|
174
|
+
(advanced/tests; default `com.dsh.computers.agent`).
|
|
175
|
+
|
|
176
|
+
## Security notes
|
|
177
|
+
|
|
178
|
+
- **Token**: generated by the server, stored only on this machine in a `0600`
|
|
179
|
+
file, sent as `authorization: Bearer …` on every request. It is never printed,
|
|
180
|
+
never logged, and `status` shows only its last 6 characters.
|
|
181
|
+
- **Outbound only**: enroll, SSE stream, result POSTs, heartbeats. No listener.
|
|
182
|
+
- **Execution identity**: commands run as your user through
|
|
183
|
+
`/bin/sh -lc '<command>'` (Windows: `cmd.exe /d /s /c`), inheriting your
|
|
184
|
+
environment — the same power as your own terminal. Only connect your server
|
|
185
|
+
to machines and networks you trust.
|
|
186
|
+
- **Process control**: each command is spawned `detached`, so on timeout/cancel
|
|
187
|
+
the whole process group is killed with `SIGKILL` (`process.kill(-pid)`),
|
|
188
|
+
including pipelines and grandchildren. Output is capped at 1 MiB per stream
|
|
189
|
+
with `\n[output truncated]` appended and `truncated: true` in the result.
|
|
190
|
+
- **Log**: rotates at 5 MiB; also captures launchd's stdout/stderr. Contains
|
|
191
|
+
command text and exit codes, never the token.
|
|
192
|
+
|
|
193
|
+
## Uninstall
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
node ~/.dsh-computers/agent.mjs service uninstall # idempotent
|
|
197
|
+
rm -rf ~/.dsh-computers # credentials + logs
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
On the server side, delete the computer entry in Settings → 电脑 (its token is
|
|
201
|
+
hashed at rest there and every stored token stops working once the record is
|
|
202
|
+
removed).
|
|
203
|
+
|
|
204
|
+
## Troubleshooting
|
|
205
|
+
|
|
206
|
+
| Symptom | Meaning / fix |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| `run` exits with `✗ 服务器拒绝了已保存的凭据(HTTP 401 invalid-token)` | The server explicitly answered JSON `{"error":"invalid-token"}`: the token was revoked or the server state was reset. Generate a new key and `enroll` again. This is the one error the agent does **not** retry. |
|
|
209
|
+
| A bare `401` / `403` / `404` / `503` while connecting | That is **not** a token rejection (plugin reloading, generic `/api` gateway fence, proxy). The agent logs a throttled `⚠ 服务器暂时不可用…` at most once a minute and keeps retrying — machines survive plugin disable/enable and upgrades. |
|
|
210
|
+
| `status` says 未注册, exit 1 | No `config.json`; enroll first. |
|
|
211
|
+
| `注册失败(invalid-key / key-expired / key-used)` | The one-time key is wrong, older than 15 min, or already used — create a new one in Settings → 连接电脑. |
|
|
212
|
+
| Service not running (`service status`) | `service start`, then check `~/.dsh-computers/agent.log`. `KeepAlive` restarts it after crashes with a 10 s throttle. |
|
|
213
|
+
| `launchctl bootstrap` fails (`5: Input/output error` / domain unsupported) | The `gui/<uid>` domain is unavailable — usually an SSH session without a GUI login. The agent falls back to `launchctl load -w`; if both fail, run `service start` once from a logged-in desktop terminal. |
|
|
214
|
+
| `✗ 需要 Node.js >= 22` | Upgrade Node; the script relies on global `fetch` and modern ESM. |
|
|
215
|
+
| Computer shows offline in the UI | Check `agent.log` for reconnect lines; the stream reconnects with 1 s → 30 s backoff. |
|
|
216
|
+
|
|
217
|
+
## Protocol notes (intentional choices)
|
|
218
|
+
|
|
219
|
+
- **Only our own `invalid-token` 401 is fatal.** A rejected **token** cannot
|
|
220
|
+
heal by retrying, so `run` logs `✗ … HTTP 401 invalid-token` and exits
|
|
221
|
+
non-zero. The classification is deliberately narrow: HTTP 401 **and** a JSON
|
|
222
|
+
body with `error === 'invalid-token'` (the host plugin's rejection shape).
|
|
223
|
+
Any other 401 (bare text, another JSON error, empty body) — and 403/404/5xx —
|
|
224
|
+
is a transient server-side condition (plugin reload, gateway fence, proxy)
|
|
225
|
+
and is retried with the normal 1 s → 30 s backoff, warning at most once per
|
|
226
|
+
60 s. Without this, disabling/enabling or upgrading the hub plugin would
|
|
227
|
+
knock every connected machine offline until the user re-ran the installer.
|
|
228
|
+
- **Heartbeats** call `POST /api/dsh-computers/agent/heartbeat` every
|
|
229
|
+
`heartbeatMs` (from `hello`). This is belt-and-braces next to the open
|
|
230
|
+
stream and also picks up renames echoed by the server.
|
|
231
|
+
- **`enroll --replace`** sends `"replace": true` in the enroll body (the host
|
|
232
|
+
also keys replacement off the pending key). It does not change local
|
|
233
|
+
behaviour otherwise.
|
|
234
|
+
- **`DSH_COMPUTERS_SERVICE_LABEL`** exists only so tests can install/remove a
|
|
235
|
+
throw-away launchd job instead of touching a real one.
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
`agent/test/mock-server.mjs` is an in-memory implementation of PROTOCOL
|
|
240
|
+
§3.1–§3.4 (enroll, SSE events, results, heartbeat) on an ephemeral port; the
|
|
241
|
+
tests drive the real CLI against it.
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
node --check agent/dsh-computers-agent.mjs
|
|
245
|
+
|
|
246
|
+
# The documented gate. Node 24 no longer expands a directory positional, so
|
|
247
|
+
# agent/test/package.json only carries a "main" shim that keeps this exact
|
|
248
|
+
# command working. The explicit glob form works on its own too:
|
|
249
|
+
node --test agent/test/
|
|
250
|
+
node --test "agent/test/**/*.test.mjs"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
To poke at the agent by hand:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
node agent/test/mock-server.mjs --port 0 --verbose
|
|
257
|
+
# prints a ready-to-paste enroll command and the mock URL
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Covered by the suite: enroll happy path / invalid / expired / used key,
|
|
261
|
+
`--replace` re-enroll, token redaction, `0600` + atomic config, SSE connect →
|
|
262
|
+
exec → result, exit codes + stdout/stderr, default and explicit `cwd`,
|
|
263
|
+
environment inheritance, timeout process-group kill, truncation at 1 MiB,
|
|
264
|
+
cancel process-group kill, reconnect after a dropped stream, heartbeats,
|
|
265
|
+
server-driven rename, log rotation, idempotent launchd
|
|
266
|
+
install/status/stop/uninstall, plus the runtime-mode and 401-classification
|
|
267
|
+
paths:
|
|
268
|
+
|
|
269
|
+
- import is side-effect free and exports `startRuntime` + `AGENT_VERSION`;
|
|
270
|
+
- embedded `startRuntime()` connects to the mock, executes a command,
|
|
271
|
+
`status()` reports `connected`/`since`, `stop()` closes the stream, reaps the
|
|
272
|
+
`sleep` child (process group), posts no result after shutdown, and returns
|
|
273
|
+
`running: false, connected: false, exitCode: 0`;
|
|
274
|
+
- a foreign bare/other-JSON 401 is retried and the machine reconnects (throttled
|
|
275
|
+
single warning), while JSON `invalid-token` exits 1 without retry;
|
|
276
|
+
- 404/503 during connect are retried and the stream recovers.
|