dsh-computers 0.0.0-stage → 1.3.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/README.md CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # dsh-computers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ **推荐从 npm 安装**(和官方插件包一样):
30
+
31
+ ```bash
32
+ # 方式 A:CLI
33
+ dsh plugin install dsh-computers
34
+
35
+ # 方式 B:DSH 设置 → 插件市场 → 安装,搜 dsh-computers
36
+ ```
37
+
38
+ 也可以直接从 GitHub 装(跟随仓库最新提交):
39
+
40
+ ```bash
41
+ dsh plugin install github:9Ashwin/dsh-computers
42
+ ```
43
+
44
+ ### 更新
45
+
46
+ **npm 安装**:插件市场 → 已安装 → 「检查更新」即可,或者在 CLI 里装具体版本:
47
+
48
+ ```bash
49
+ dsh plugin install dsh-computers@1.3.0
50
+ ```
51
+
52
+ **GitHub 安装**:profile 里是按 spec + commit 固定的(`github:user/repo` 或 `#v1.0.2`),所以
53
+
54
+ - 用**同一个 spec** 再装一次只会得到 `already-installed`(这就是「再安装没法更新」的原因);
55
+ - 装 **tag** 会被冻住,市场永远检测不到更新;装**分支**(不带 `#`)才能被市场「检查更新」发现新提交;
56
+ - 手动升级到某个版本要**换 spec**:`dsh plugin install github:9Ashwin/dsh-computers#v1.3.0`。
57
+
58
+ > 更新完记得重启 Host 并刷新页面,具体见 [docs/deploy.md](./docs/deploy.md)。
59
+
60
+ 装好后重启 `dsh web`,设置里会出现 **电脑** 页。
61
+
62
+ 服务端建议把 `role` 固定成 `hub`(只当主控)。**对外地址会自动识别**:你从哪个域名打开设置页(含反向代理的 `X-Forwarded-Proto/Host`、`Forwarded` 头),生成的命令就用哪个地址,通常不用配。需要强制指定时才写 `publicUrl`:
63
+
64
+ ```yaml
65
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml
66
+ - id: dsh-computers
67
+ name: dsh-computers
68
+ config:
69
+ role: hub
70
+ # 留空 = 按浏览器请求自动推导(含反代的 X-Forwarded-* / Forwarded);
71
+ # 只有不想信任代理时才显式写死,例如 https://dsh.example.com
72
+ publicUrl: ''
73
+ execEnabled: true # false 时拒绝所有远程执行
74
+ execTimeoutMs: 120000 # 单条命令默认超时
75
+ keyTtlMinutes: 15 # 加入密钥有效期
76
+ ```
77
+
78
+ > **安全前提**:请开启 DSH 的浏览器登录/可信来源(`dsh web` 默认就有),不要把裸端口暴露给公网。插件给"能使用这个 DSH 的智能体"的权限,等于能在这台电脑上执行任意命令。
79
+
80
+ ## 2. 每台电脑:装同一个插件(被控)
81
+
82
+ 1. 按上一节的方式在**这台电脑**的 DSH 里装上 `dsh-computers`(桌面端 / 命令行装法相同)。
83
+ 2. 打开 DSH → 设置 → 电脑,在 **本机运行时** 卡片里填:
84
+ - **服务器地址**:你服务器的对外地址,如 `https://dsh.example.com`
85
+ - **加入密钥**:在服务器的 设置 → 电脑 → `+ 连接电脑` 里复制的 `dshc_…`
86
+ 3. 点 **连接**,卡片变成「运行中」,服务器列表里随即出现这台电脑(在线)。
87
+
88
+ 被控端默认 `role: both`,所以这台电脑自己也能当主控;只想当被控端就设 `role: runtime`。
89
+
90
+ 运行时的开关是持久的:**DSH 一启动就自动连回服务器**,不用每次手动点。关掉 DSH 就离线。
91
+
92
+ ### 兜底:不装 DSH 的机器
93
+
94
+ Mac mini 常驻、Linux 服务器这类不跑桌面的机器,用服务器生成的命令(launchd / systemd 常驻):
95
+
96
+ ```bash
97
+ curl -fsSL 'https://<你的服务器>/api/dsh-computers/join.sh?key=dshc_xxx' | sh
98
+ ```
99
+
100
+ 它会下载 agent 到 `~/.dsh-computers/`、注册系统服务并启动。手动管理:
101
+
102
+ ```bash
103
+ node ~/.dsh-computers/agent.mjs status
104
+ node ~/.dsh-computers/agent.mjs service restart
105
+ node ~/.dsh-computers/agent.mjs service uninstall
106
+ tail -f ~/.dsh-computers/agent.log
107
+ ```
108
+
109
+ ## 3. 对话里怎么用
110
+
111
+ 智能体会看到这些工具(`computer` 参数都可省略,省略时用输入框左侧选中的目标电脑):
112
+
113
+ | 工具 | 作用 |
114
+ |---|---|
115
+ | `computer_list` | 列出可用电脑(名字、平台、在线、能力、当前固定目标) |
116
+ | `computer_exec` | 执行命令,支持 `run_in_background`;超时默认**转后台**而不是杀掉 |
117
+ | `computer_read` | 读远端文件(offset/limit 行) |
118
+ | `computer_write` | 写远端文件(会自动建目录) |
119
+ | `computer_edit` | 字面替换(`old` 必须唯一,除非 `replaceAll`) |
120
+ | `computer_glob` | 远端 glob(`*`、`?`、`**`、`{a,b}`) |
121
+ | `computer_grep` | 远端正则搜索(支持 `include`、命中上限) |
122
+
123
+ 文件类工具需要那台电脑的 agent 上报 `fs` 能力(见下)。
124
+
125
+ 系统提示里会自动附上当前电脑清单,所以直接说「看看两台 mac 的内存」它就会分别调用。
126
+
127
+ ## 3.1 能力、流式与后台任务
128
+
129
+ - **能力协商**:agent 在握手时上报 `capabilities`(`exec` / `fs`,以后还有 `pty`)和 `workspaceBase`(默认工作目录)。服务器据此决定是否提供文件类工具;agent 过旧时工具调用会明确提示"升级这台电脑的 agent",设置页也会显示 `可升级`。
130
+ - **流式**:长命令的分块输出会随帧回传(含心跳),后台任务的输出可在 DSH 的任务里查看。
131
+ - **超时转后台**:前台命令超时默认不杀(`promoteOnTimeout: true`),而是转入后台任务并返回 jobId,之后用内置的 `job_output` / `job_kill` 接管。
132
+ - **激活时机**:新电脑要等 agent 首次建流后才出现在列表(从生成密钥到真正连上之间,弹窗会一直显示"等待电脑连接…")。
133
+
134
+ ## 4. 端点一览
135
+
136
+ | 方法 | 路径 | 说明 |
137
+ |---|---|---|
138
+ | POST | `/api/dsh-computers/agent/enroll` | 加入密钥换取电脑 token(runtime 卡片也走这里) |
139
+ | GET | `/api/dsh-computers/agent/events` | 被控端的 SSE 长连接(Bearer token) |
140
+ | POST | `/api/dsh-computers/agent/results` | 命令执行结果回传(最终结果,权威) |
141
+ | POST | `/api/dsh-computers/agent/results/chunk` | 流式输出分块(stdout/stderr/heartbeat) |
142
+ | POST | `/api/dsh-computers/agent/fs/result` | 远端文件操作结果 |
143
+ | POST | `/api/dsh-computers/agent/heartbeat` | 心跳 |
144
+ | GET | `/api/dsh-computers/state` | 设置页数据(角色、电脑列表,需要浏览器会话) |
145
+ | POST | `/api/dsh-computers/keys` | 生成一次性加入密钥 |
146
+ | GET | `/api/dsh-computers/key-status` | 弹窗轮询用 |
147
+ | POST | `/api/dsh-computers/delete` / `rename` | 删除 / 重命名电脑 |
148
+ | GET | `/api/dsh-computers/runtime` | 本机运行时状态 |
149
+ | POST | `/api/dsh-computers/runtime/connect` / `enable` / `disconnect` | 连接 / 启用开关 / 断开本机运行时 |
150
+ | GET | `/api/dsh-computers/join.sh` | 兜底安装脚本 |
151
+ | GET | `/api/dsh-computers/agent.mjs` | agent 本体(兜底脚本下载) |
152
+ | GET | `/api/dsh-computers/health` | 健康检查 |
153
+
154
+ 浏览器端点都先过 DSH 的请求闸门(`connection.requestRejection`),未登录/不可信来源直接拒绝;被控端端点用 Bearer token 认证(服务端只存 `sha256(token)`)。
155
+
156
+ ## 5. 开发与验证
157
+
158
+ ```bash
159
+ node scripts/host-smoke.mjs # Host 半:真实 HTTP 监听 + 假 agent/hub,覆盖注册/SSE/exec/超时/runtime
160
+ node --test agent/test/ # agent 半:enroll / SSE / exec / 超时 / 截断 / 内嵌运行时
161
+ node --check client.js # 浏览器半
162
+ ```
163
+
164
+ 协议细节(所有路由、SSE 事件、状态文件、工具 schema、runtime 契约)见 [PROTOCOL.md](./PROTOCOL.md)。
165
+
166
+ ## 6. 已知限制
167
+
168
+ - 一次执行返回一次性结果(不流式),长命令靠 `timeoutMs`;输出单流上限 1 MiB。
169
+ - 一台电脑同一时刻只保留一条连接(新的会顶掉旧的)。
170
+ - 在线判定只看 SSE 是否存活:杀掉进程立刻离线;网络静默(无 FIN)最多差一个心跳周期。
171
+ - Windows 被控端目前可用 `node agent.mjs run` 手动运行,服务安装只覆盖 macOS/Linux。
172
+ - 电脑与 Bot 的绑定还没有(列表里的 `1/1 Bot` 是占位徽标);v1 里所有电脑对智能体都可选。
173
+ - 不做 P2P / 电脑互控;要隔离环境请用 VPN,或把某台桌面端当 hub、另一台连过去(仍是单向)。
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,383 @@
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
+ Since v1.3.0 the agent implements PROTOCOL §11: it advertises the `exec` and
25
+ `fs` capabilities, streams live output when the host asks for it, serves remote
26
+ file operations over the same SSE stream, and reports §11.6 handshake metadata
27
+ (`agentVersion`, `hostname`, `os`, `arch`, `capabilities`, `workspaceBase`) at
28
+ enroll and on every reconnect. Old hosts that advertise no `features` get
29
+ exactly the v1 behaviour.
30
+
31
+ The file is served verbatim by the host plugin at
32
+ `/api/dsh-computers/agent.mjs`, so what you run is exactly what the repository
33
+ contains. It imports only Node built-ins, never opens a listening socket, and
34
+ never prints the token.
35
+
36
+ The wire contract lives in [`../PROTOCOL.md`](../PROTOCOL.md) (§3 and §6).
37
+
38
+ ## Requirements
39
+
40
+ - Node.js **22 or newer** (`node -v`)
41
+ - macOS (launchd), Linux (systemd `--user`) or Windows (manual, v1)
42
+ - Outbound HTTP(S) to the dsh-web server
43
+
44
+ ## Install
45
+
46
+ ### Recommended: the one-liner from Settings → 电脑
47
+
48
+ The server's **Connect computer** dialog generates a command like:
49
+
50
+ ```sh
51
+ curl -fsSL 'http://<server>/api/dsh-computers/join.sh?key=dshc_…' | sh
52
+ ```
53
+
54
+ It downloads this script to `~/.dsh-computers/agent.mjs`, enrolls with the
55
+ one-time key, installs the login service, and starts it.
56
+
57
+ ### Manual
58
+
59
+ ```sh
60
+ mkdir -p ~/.dsh-computers
61
+ curl -fsSL 'http://<server>/api/dsh-computers/agent.mjs' -o ~/.dsh-computers/agent.mjs
62
+ node ~/.dsh-computers/agent.mjs enroll --server http://<server> --key dshc_…
63
+ node ~/.dsh-computers/agent.mjs service install
64
+ node ~/.dsh-computers/agent.mjs service start
65
+ ```
66
+
67
+ Enrollment keys are single use and expire (15 minutes by default). When the
68
+ server says the machine is already connected, re-run with `--replace`:
69
+
70
+ ```sh
71
+ node ~/.dsh-computers/agent.mjs enroll --server http://<server> --key dshc_… --replace
72
+ ```
73
+
74
+ > A successful `enroll` prints `✓ 已连接 <server>,这台电脑现在是 <name>`.
75
+ > If the stream probe fails, the credentials are still saved and `run` retries.
76
+
77
+ ## Commands
78
+
79
+ ```
80
+ dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
81
+ dsh-computers-agent run
82
+ dsh-computers-agent service install|uninstall|start|stop|restart|status
83
+ dsh-computers-agent status
84
+ dsh-computers-agent --version | --help
85
+ ```
86
+
87
+ | Command | What it does |
88
+ | --- | --- |
89
+ | `enroll` | POSTs the join key (plus `capabilities` and §11.6 `metadata`) to `/api/dsh-computers/agent/enroll`, saves `{serverUrl, computerId, token, name, enrolledAt, workspaceBase}` to `config.json` (atomic, `0600`), then opens the SSE stream for ~5 s to prove the round trip. `--workspace-base` sets the default working directory (default `$HOME`). Exits non-zero with the server's message on failure. |
90
+ | `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. |
91
+ | `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. |
92
+ | `service` | Login/auto-restart service. See below. |
93
+
94
+ Ctrl-C / `SIGTERM` makes `run` exit cleanly (in-flight commands are killed).
95
+
96
+ ## The service
97
+
98
+ **macOS (launchd).** `service install` writes
99
+ `~/Library/LaunchAgents/com.dsh.computers.agent.plist` with
100
+ `ProgramArguments = [node, <absolute agent path>, run]`, `RunAtLoad`,
101
+ `KeepAlive`, and `StandardOutPath`/`StandardErrorPath` pointing at
102
+ `~/.dsh-computers/agent.log`. `service start` runs
103
+ `launchctl bootstrap gui/$(id -u) <plist>` and falls back to `launchctl load -w`.
104
+ `stop` uses `launchctl bootout` (fallback `unload -w`). `stop` and `uninstall`
105
+ are idempotent: when nothing is loaded they still exit `0`.
106
+
107
+ **Linux (systemd `--user`).** `service install` writes
108
+ `~/.config/systemd/user/dsh-computers-agent.service`, runs
109
+ `systemctl --user daemon-reload`; `start` runs
110
+ `systemctl --user enable --now ...`; `stop` runs `disable --now`. Without a
111
+ usable `systemctl --user`, `start`/`stop` fall back to `nohup … run &` with a
112
+ pid file (`~/.dsh-computers/agent.pid`).
113
+
114
+ **Windows.** v1 prints the manual command and the Task Scheduler setup and exits
115
+ `0` (no service automation yet).
116
+
117
+ ## Embedded runtime mode (`startRuntime`)
118
+
119
+ When the plugin runs inside the user's **desktop** DSH, `lib/runtime.js` imports
120
+ this same file and starts an in-process runtime — no CLI, no service, no
121
+ `~/.dsh-computers` config:
122
+
123
+ ```js
124
+ import { startRuntime } from '../agent/dsh-computers-agent.mjs'
125
+
126
+ const runtime = startRuntime({
127
+ serverUrl: 'https://dsh.example.com',
128
+ token: 'dshc_…', // from the hub's enroll response
129
+ computerId: 'cmp_…',
130
+ name: 'Desk Mac mini', // optional display name
131
+ heartbeatMs: 20000, // optional; `hello` overrides it
132
+ log: (level, message) => {}, // 'info' | 'warn' | 'error'
133
+ onState: (state) => {}, // connect/disconnect/error/stop transitions
134
+ })
135
+
136
+ runtime.status() // synchronous snapshot (see below)
137
+ await runtime.stop() // idempotent: aborts SSE, SIGKILLs children, awaits the loop
138
+ ```
139
+
140
+ `status()` returns
141
+ `{ running, connected, serverUrl, computerId, name, heartbeatMs, since, lastConnectedAt, lastError, attempts, exitCode }`
142
+ (`since`/`lastConnectedAt` are ISO strings; `lastError` is `null` when healthy).
143
+ `onState` receives the same shape and is de-duplicated to real transitions.
144
+
145
+ Semantics guaranteed to the host:
146
+
147
+ - `startRuntime()` returns immediately; the SSE loop/backoff runs in the
148
+ background. The module is **import-safe**: importing it never runs CLI code,
149
+ touches the filesystem, prints, or starts child processes (the CLI main only
150
+ runs when this file is the process entry point, via `process.argv[1]` +
151
+ `pathToFileURL`, handling relative and symlinked paths).
152
+ - `stop()` aborts the SSE fetch, retains no timers, sends `SIGKILL` to every
153
+ in-flight command's process group and **waits** for those children to be
154
+ reaped before resolving. It is safe to call twice (and from inside `onState`).
155
+ - A fatal `{"error":"invalid-token"}` 401 never throws: the loop ends,
156
+ `status()` reports `lastError: 'invalid-token'` and `exitCode: 1`, and
157
+ `onState` fires so the host can surface it. Any other 401/403/404/5xx is
158
+ treated as a transient server condition and retried with backoff.
159
+ - The embedded runtime never writes `~/.dsh-computers`; the host persists its
160
+ own `runtime.json` (PROTOCOL §10.2) and the CLI remains the only writer of
161
+ `config.json`.
162
+
163
+ `AGENT_VERSION` is exported too (the host can put it in the §3.1 enroll body).
164
+
165
+ ## Protocol v1.3.0 / §11 features
166
+
167
+ ### Capabilities and gating (§11.1, §11.5)
168
+
169
+ The agent reports `capabilities: ["exec", "fs"]` in the enroll body, in the
170
+ heartbeat `meta`, in the §11.6 metadata and in `startRuntime().status()`.
171
+ Nothing new is sent to an old host: the `hello` event's `features` list decides
172
+ what is enabled per connection —
173
+
174
+ | `hello.features` | behaviour |
175
+ | --- | --- |
176
+ | absent / no `stream`, no `fs` | exactly v1: `exec` results only, `fs` events ignored, no chunk POSTs |
177
+ | `stream` | `exec` with `"stream": true` gets incremental chunk POSTs |
178
+ | `fs` | `fs` events are served and answered on `/agent/fs/result` |
179
+
180
+ ### Handshake metadata (§11.6)
181
+
182
+ Enroll sends `metadata: { version: 1, agentVersion, hostname, os, arch,
183
+ capabilities, workspaceBase }`, and every SSE connect (probe and each
184
+ reconnect) carries the same JSON in the `x-dsh-computers-metadata` header as
185
+ base64url — an SSE GET has no body, so the header is how the reference-product
186
+ handshake metadata maps onto this wire. `workspaceBase` comes from
187
+ `config.json` (`--workspace-base`, default `$HOME`), is the default `cwd` for
188
+ `exec`, and is the base for relative `glob`/`grep` patterns.
189
+
190
+ ### Streaming results (§11.2 + §11.6 frames)
191
+
192
+ When the host advertises `stream` and an `exec` event has `stream: true`, the
193
+ agent POSTs frames to `/api/dsh-computers/agent/results/chunk`:
194
+
195
+ ```json
196
+ { "requestId": "req_…", "seq": 0, "type": "stdout", "data": "<base64>" }
197
+ ```
198
+
199
+ - `type` is `stdout`, `stderr`, or `heartbeat` (empty `data`).
200
+ - `seq` starts at 0 and increases by one per frame; frames are posted strictly
201
+ sequentially so arrival order matches `seq` (the host can still drop dupes).
202
+ - Batches flush at most every 100 ms or 32 KiB (whichever first); a heartbeat
203
+ frame is emitted after each 10 s of silence from a running command.
204
+ - A failed chunk POST retries once, then that batch is dropped — the command
205
+ keeps running and the final authoritative `POST /agent/results` still carries
206
+ the capped `stdout`/`stderr` buffers, `exitCode`, `durationMs`, `timedOut`
207
+ and `truncated`.
208
+ - `stop()` drops buffered frames, clears every chunk timer and leaves no pending
209
+ POSTs (they are aborted with the runtime).
210
+
211
+ ### Remote file access (§11.3)
212
+
213
+ `fs` events are served in-process (no shell) and answered on
214
+ `POST /api/dsh-computers/agent/fs/result` with the §11.3 field set.
215
+
216
+ | op | request fields | response highlights |
217
+ | --- | --- | --- |
218
+ | `read` | `path`, `offset?`, `limit?`, `encoding?` (`utf8`/`base64`) | `encoding`, `text` (utf8) or `content` (base64), `bytes`, `lines`, `truncated` |
219
+ | `write` | `path`, `content`, `encoding?`, `createDirs?` | `bytes`, `created` |
220
+ | `list` | `path` | `entries[{name,path,type,size,mtimeMs}]`, `truncated` |
221
+ | `stat` | `path` | `stat{type,size,mtimeMs,mode}` |
222
+ | `mkdir` | `path`, `recursive?` | `created` |
223
+ | `rename` | `path`, `to` | `path` (new) |
224
+ | `delete` | `path`, `recursive?` | `ok` |
225
+ | `glob` | `pattern` (`*`, `?`, `**`, `{a,b}`) | `paths`, `truncated` |
226
+ | `grep` | `pattern` (RegExp), `path?`, `include?` (glob), `maxMatches?` | `matches[{path,line,text}]`, `truncated` |
227
+
228
+ Rules and limits:
229
+
230
+ - `read`/`write`/`list`/`stat`/`mkdir`/`rename`/`delete` require **absolute**
231
+ paths (`ok:false, error:"path must be absolute"`); `glob`/`grep` accept
232
+ relative paths/patterns resolved against `workspaceBase`.
233
+ - read 1 MiB / 20 000 lines, write 4 MiB, list 5 000 entries, glob 2 000
234
+ paths, grep 500 matches, 15 s per request (a timed-out request answers
235
+ `ok:false, error:"request timed out"`).
236
+ - Text vs binary: a NUL byte in the first 8 KiB (or `encoding:"base64"`) makes
237
+ the read return base64 `content`; everything else returns utf8 `text`.
238
+ - Symlinks: `read` follows them like `cat`; `stat`/`list` report the link
239
+ itself; directory walks never follow symlinked directories (loop safety) and
240
+ skip `.git`; `write` follows an existing symlink.
241
+ - grep skips binary files and files larger than 8 MiB; `include` matches the
242
+ basename or workspace-relative path.
243
+ - OS errors are returned verbatim in `error` (e.g. `ENOENT: no such file or
244
+ directory, open '…'`); a request never throws out of the loop.
245
+
246
+ `startRuntime().status()` additionally exposes `workspaceBase`, `capabilities`
247
+ and the last advertised `features`.
248
+
249
+ ## Files and environment
250
+
251
+ | Path | Purpose |
252
+ | --- | --- |
253
+ | `${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers}/config.json` | Credentials + `workspaceBase`, mode `0600`, atomic tmp+rename |
254
+ | `${DSH_COMPUTERS_HOME}/agent.log` | Append-only log, rotates at 5 MiB (keeps one `agent.log.1`) |
255
+ | `~/Library/LaunchAgents/com.dsh.computers.agent.plist` | macOS service unit |
256
+ | `~/.config/systemd/user/dsh-computers-agent.service` | Linux service unit |
257
+ | `~/.dsh-computers/agent.pid` | Only used by the `nohup` fallback |
258
+
259
+ Environment variables:
260
+
261
+ - `DSH_COMPUTERS_HOME` — relocate config + log (useful for tests and for
262
+ multiple installs). Also written into the service unit so the service reads
263
+ the same credentials.
264
+ - `DSH_COMPUTERS_SERVICE_LABEL` — override the launchd/systemd label
265
+ (advanced/tests; default `com.dsh.computers.agent`).
266
+
267
+ ## Security notes
268
+
269
+ - **Token**: generated by the server, stored only on this machine in a `0600`
270
+ file, sent as `authorization: Bearer …` on every request. It is never printed,
271
+ never logged, and `status` shows only its last 6 characters.
272
+ - **Outbound only**: enroll, SSE stream, result POSTs, heartbeats, chunk/fs
273
+ result POSTs. No listener.
274
+ - **Remote files**: when the server advertises the `fs` feature it can read and
275
+ write files as your user, but only through the ops above; paths outside
276
+ `workspaceBase` are allowed when absolute (documented §11.3 semantics), while
277
+ relative paths are anchored at `workspaceBase`. Directory walks skip `.git`
278
+ and symlinked directories.
279
+ - **Execution identity**: commands run as your user through
280
+ `/bin/sh -lc '<command>'` (Windows: `cmd.exe /d /s /c`), inheriting your
281
+ environment — the same power as your own terminal. Only connect your server
282
+ to machines and networks you trust.
283
+ - **Process control**: each command is spawned `detached`, so on timeout/cancel
284
+ the whole process group is killed with `SIGKILL` (`process.kill(-pid)`),
285
+ including pipelines and grandchildren. Output is capped at 1 MiB per stream
286
+ with `\n[output truncated]` appended and `truncated: true` in the result.
287
+ - **Log**: rotates at 5 MiB; also captures launchd's stdout/stderr. Contains
288
+ command text and exit codes, never the token.
289
+
290
+ ## Uninstall
291
+
292
+ ```sh
293
+ node ~/.dsh-computers/agent.mjs service uninstall # idempotent
294
+ rm -rf ~/.dsh-computers # credentials + logs
295
+ ```
296
+
297
+ On the server side, delete the computer entry in Settings → 电脑 (its token is
298
+ hashed at rest there and every stored token stops working once the record is
299
+ removed).
300
+
301
+ ## Troubleshooting
302
+
303
+ | Symptom | Meaning / fix |
304
+ | --- | --- |
305
+ | `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. |
306
+ | 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. |
307
+ | `status` says 未注册, exit 1 | No `config.json`; enroll first. |
308
+ | `注册失败(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 → 连接电脑. |
309
+ | Service not running (`service status`) | `service start`, then check `~/.dsh-computers/agent.log`. `KeepAlive` restarts it after crashes with a 10 s throttle. |
310
+ | `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. |
311
+ | `✗ 需要 Node.js >= 22` | Upgrade Node; the script relies on global `fetch` and modern ESM. |
312
+ | Computer shows offline in the UI | Check `agent.log` for reconnect lines; the stream reconnects with 1 s → 30 s backoff. |
313
+ | Streaming/file tools unavailable after upgrading | The host must advertise `stream`/`fs` in `hello.features`; old hosts get v1 behaviour by design (§11.5). The agent log line `已连接 … features=…` shows what the host offered. |
314
+
315
+ ## Protocol notes (intentional choices)
316
+
317
+ - **Only our own `invalid-token` 401 is fatal.** A rejected **token** cannot
318
+ heal by retrying, so `run` logs `✗ … HTTP 401 invalid-token` and exits
319
+ non-zero. The classification is deliberately narrow: HTTP 401 **and** a JSON
320
+ body with `error === 'invalid-token'` (the host plugin's rejection shape).
321
+ Any other 401 (bare text, another JSON error, empty body) — and 403/404/5xx —
322
+ is a transient server-side condition (plugin reload, gateway fence, proxy)
323
+ and is retried with the normal 1 s → 30 s backoff, warning at most once per
324
+ 60 s. Without this, disabling/enabling or upgrading the hub plugin would
325
+ knock every connected machine offline until the user re-ran the installer.
326
+ - **Heartbeats** call `POST /api/dsh-computers/agent/heartbeat` every
327
+ `heartbeatMs` (from `hello`). This is belt-and-braces next to the open
328
+ stream and also picks up renames echoed by the server.
329
+ - **`enroll --replace`** sends `"replace": true` in the enroll body (the host
330
+ also keys replacement off the pending key). It does not change local
331
+ behaviour otherwise.
332
+ - **`DSH_COMPUTERS_SERVICE_LABEL`** exists only so tests can install/remove a
333
+ throw-away launchd job instead of touching a real one.
334
+
335
+ ## Development
336
+
337
+ `agent/test/mock-server.mjs` is an in-memory implementation of PROTOCOL
338
+ §3.1–§3.4 (enroll, SSE events, results, heartbeat) on an ephemeral port; the
339
+ tests drive the real CLI against it.
340
+
341
+ ```sh
342
+ node --check agent/dsh-computers-agent.mjs
343
+
344
+ # The documented gate. Node 24 no longer expands a directory positional, so
345
+ # agent/test/package.json only carries a "main" shim that keeps this exact
346
+ # command working. The explicit glob form works on its own too:
347
+ node --test agent/test/
348
+ node --test "agent/test/**/*.test.mjs"
349
+ ```
350
+
351
+ To poke at the agent by hand:
352
+
353
+ ```sh
354
+ node agent/test/mock-server.mjs --port 0 --verbose
355
+ # prints a ready-to-paste enroll command and the mock URL
356
+ ```
357
+
358
+ Covered by the suite: enroll happy path / invalid / expired / used key,
359
+ `--replace` re-enroll, token redaction, `0600` + atomic config, SSE connect →
360
+ exec → result, exit codes + stdout/stderr, default and explicit `cwd`,
361
+ environment inheritance, timeout process-group kill, truncation at 1 MiB,
362
+ cancel process-group kill, reconnect after a dropped stream, heartbeats,
363
+ server-driven rename, log rotation, idempotent launchd
364
+ install/status/stop/uninstall, plus the runtime-mode and 401-classification
365
+ paths:
366
+
367
+ - import is side-effect free and exports `startRuntime` + `AGENT_VERSION`;
368
+ - embedded `startRuntime()` connects to the mock, executes a command,
369
+ `status()` reports `connected`/`since`, `stop()` closes the stream, reaps the
370
+ `sleep` child (process group), posts no result after shutdown, and returns
371
+ `running: false, connected: false, exitCode: 0`;
372
+ - a foreign bare/other-JSON 401 is retried and the machine reconnects (throttled
373
+ single warning), while JSON `invalid-token` exits 1 without retry;
374
+ - 404/503 during connect are retried and the stream recovers;
375
+ - §11: enroll reports `capabilities:['exec','fs']` + full handshake metadata,
376
+ the metadata header is on every SSE connect, `exec` defaults to
377
+ `workspaceBase`, typed chunk frames arrive with contiguous `seq` (stdout,
378
+ stderr and silent-command heartbeats), chunk POSTs retry once / drop without
379
+ killing the command, an old host (no `features`) gets no chunks and no fs,
380
+ and the fs ops round-trip (read text/binary + windows + limits, relative-path
381
+ rejection, write/list/stat/mkdir/rename/delete, glob with `**` and `{a,b}`,
382
+ grep with `include`/`maxMatches`/binary skip, and `stop()` during an
383
+ in-flight fs request leaves no result POST).