dsh-computers 1.1.0 → 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/PROTOCOL.md CHANGED
@@ -557,3 +557,138 @@ the raw materials for the desktop path: below the command box, a muted line
557
557
  「也可以在这台电脑的 DSH 桌面端打开 设置 → 电脑 → 本机运行时,填入下面的地址和密钥」 plus
558
558
  two compact copyable rows — the server URL and the enrollment key (`/keys` already returns
559
559
  `serverUrl` and `key`). That is how a user connects a machine without a terminal.
560
+
561
+ ---
562
+
563
+ ## 11. Stage 1 (v1.3.0): capabilities, streaming, remote files
564
+
565
+ Everything here is additive: an agent that reports no capabilities keeps the v1 behaviour
566
+ exactly. The wire stays SSE (host → agent) + HTTP POST (agent → host); no inbound ports.
567
+
568
+ ### 11.1 Capabilities and version gate
569
+
570
+ `POST /agent/enroll` and `POST /agent/heartbeat` carry
571
+ `"capabilities": ["exec" | "fs" | "pty"]` and `agentVersion` (already present). The registry
572
+ stores the last reported set and `capabilitiesUpdatedAt`. Rules:
573
+
574
+ - A computer with no reported capabilities is treated as `["exec"]` (v1 agents).
575
+ - Host tool availability is gated per session: the `computer_*_file` tools are only
576
+ registered/offered when at least one allowed computer reports `fs`; the terminal backend
577
+ only serves computers reporting `pty`. Gating uses `ctx.tools.restrict`-style filtering
578
+ (drop the schema) rather than failing at call time, so the model never sees a tool it
579
+ cannot use; a call that still arrives for an incapable computer answers with an
580
+ actionable "this machine's agent does not support file access (upgrade the agent)".
581
+ - `hello` echoes the host's view: `{ computerId, name, heartbeatMs, capabilities }`.
582
+ - `/state` and `/health` expose each computer's `agentVersion` and `capabilities`; the
583
+ settings page shows a drift badge when `agentVersion` differs from the host plugin's.
584
+
585
+ ### 11.2 Streaming results
586
+
587
+ `exec` gains `"stream": true`. The agent then POSTs incremental output to
588
+ `POST /api/dsh-computers/agent/results/chunk`:
589
+
590
+ ```json
591
+ { "requestId": "req_…", "seq": 0, "stdout": "<base64>", "stderr": "<base64>" }
592
+ ```
593
+
594
+ - `seq` starts at 0 and increases by 1; the host drops out-of-order duplicates and
595
+ reassembles. Batches are sent at most every 100 ms (or 32 KiB, whichever first).
596
+ - The final `POST /agent/results` stays authoritative for `exitCode`/`durationMs`/
597
+ `timedOut`/`truncated` and carries the same capped buffers as v1 (so a host that missed
598
+ chunks still ends with the tail).
599
+ - Host side, a streaming execution feeds the **DSH jobs** registry: `computer_exec` with
600
+ `run_in_background: true` registers one job (`kind: 'computer-exec'`, label
601
+ `<name>: <command>`, owner = calling session), returns its id immediately, and appends
602
+ every chunk to the job's output ring so the built-in `job_output` / `job_kill` tools work
603
+ unchanged. Killing the job sends `cancel` to the agent and settles the job.
604
+ - A foreground call still returns the complete result (v1 shape) but the host keeps the
605
+ tail in the job ring for the conversation's progress view.
606
+
607
+ ### 11.3 Remote file access
608
+
609
+ Host → agent, over the same SSE stream:
610
+
611
+ ```json
612
+ { "event": "fs", "data": { "requestId": "fsr_…", "op": "read", "path": "/abs", "encoding": "utf8", "offset": 0, "limit": 2000 } }
613
+ ```
614
+
615
+ `op` ∈ `read | write | list | stat | mkdir | rename | delete | glob | grep`.
616
+ Agent → host: `POST /api/dsh-computers/agent/fs/result`
617
+
618
+ ```json
619
+ { "requestId": "fsr_…", "ok": true, "op": "read", "path": "/abs",
620
+ "content": "<base64 for write/read binary>", "text": "<utf8 for text reads>",
621
+ "encoding": "utf8|base64", "bytes": 123, "lines": 20, "truncated": false,
622
+ "entries": [{ "name": "a", "path": "/abs/a", "type": "file|dir|symlink", "size": 1, "mtimeMs": 0 }],
623
+ "stat": { "type": "file", "size": 1, "mtimeMs": 0, "mode": 420 },
624
+ "matches": [{ "path": "/abs/a", "line": 3, "text": "…" }],
625
+ "error": null }
626
+ ```
627
+
628
+ Limits: read 1 MiB / 20 000 lines, write 4 MiB, list 5 000 entries, glob 2 000 paths,
629
+ grep 500 matches, per-request timeout 15 s. Paths must be absolute; the agent rejects a
630
+ relative path with `ok:false, error:"path must be absolute"`.
631
+
632
+ Host tools (registered only when a `fs`-capable computer is available, §11.1):
633
+
634
+ | tool | arguments (JSON Schema) | value |
635
+ |---|---|---|
636
+ | `computer_read` | `computer?`, `path`, `offset?`, `limit?` | `{ computer, path, text, lines, bytes, truncated }` |
637
+ | `computer_write` | `computer?`, `path`, `content`, `createDirs?` | `{ computer, path, bytes, created }` |
638
+ | `computer_edit` | `computer?`, `path`, `old`, `new`, `replaceAll?` | `{ computer, path, replacements, bytes }` |
639
+ | `computer_glob` | `computer?`, `pattern`, `cwd?` | `{ computer, paths }` |
640
+ | `computer_grep` | `computer?`, `pattern`, `path?`, `include?`, `maxMatches?` | `{ computer, matches }` |
641
+
642
+ `computer` is optional in all of them and falls back to the session pin (§10/§9 selection),
643
+ same as `computer_exec`. Renders mirror the built-in `read`/`write`/`edit`/`glob`/`grep`
644
+ tool output style so the model reads them the same way. A write/edit to a path the agent
645
+ cannot touch answers `ok:false` with the OS error text, surfaced as a tool error.
646
+
647
+ ### 11.4 PTY (terminal backend) — follows in v1.4.0
648
+
649
+ Reserved events, not implemented in v1.3.0:
650
+ `{"event":"pty.open"|"pty.input"|"pty.resize"|"pty.close", data:{ ptyId, … }}` and
651
+ `POST /agent/pty/out` `{ ptyId, seq, data(base64) }` + `POST /agent/pty/exit` `{ ptyId, exitCode }`.
652
+ The host will register a DSH `terminals` backend (`ctx.terminals.registerBackend`) so the
653
+ built-in terminal tools and UI work against a connected computer; agents advertise `pty`
654
+ only once they implement it.
655
+
656
+ ### 11.5 Compatibility
657
+
658
+ - v1 agents: no capabilities, no streaming, no fs — everything above degrades to §3/§4.
659
+ - New host + old agent: `computer_exec` unchanged; file tools hidden; a manual call errors
660
+ with the upgrade hint.
661
+ - New agent + old host: the agent never sends `stream:true` payloads unless the host's
662
+ `hello` advertises `"features":["stream","fs","pty"]`, so an old host is unaffected.
663
+
664
+ ### 11.6 Interaction details aligned with the reference product
665
+
666
+ These mirror the reference implementation's runtime protocol *behaviour* (handshake metadata,
667
+ stream frames, background adoption, update-required) without copying any of its code.
668
+
669
+ 1. **Handshake metadata.** The agent's enroll body and every reconnect carry a metadata block:
670
+ `{ version: 1, agentVersion, hostname, os, arch, capabilities, workspaceBase }`, where
671
+ `workspaceBase` is the agent's default working directory (config `workspaceBase`, default
672
+ `os.homedir()`), reported back to the host at connect and used as the default `cwd` for
673
+ `exec` and the base for relative `glob`/`grep`. The host stores it and shows it in the
674
+ computer detail view. Same shape as the reference product's `RuntimeHandshakeMetadataV1`.
675
+ 2. **Stream frames.** Chunk payloads carry a `type`:
676
+ `{ type: 'stdout' | 'stderr' | 'heartbeat', data(base64, empty for heartbeat) }`.
677
+ The agent emits a `heartbeat` frame for every 10 s of a silent command so the host can keep
678
+ the execution alive and the conversation's progress view ticking instead of timing out.
679
+ Exit code still arrives exactly once in the final `/agent/results`.
680
+ 3. **Timeout adopts instead of killing.** When a foreground `computer_exec` reaches
681
+ `timeoutMs`, the host does NOT kill it by default: it moves the still-running execution
682
+ into the background-job path (config `promoteOnTimeout`, default true), returns `{ jobId,
683
+ promoted: true }`, and the model continues with `job_output`/`job_kill`. With
684
+ `promoteOnTimeout: false` the v1 cancel-and-error behaviour applies.
685
+ 4. **Pending → activated lifecycle.** An enrollment key is `pending` until the agent's first
686
+ successful stream; that moment sets `activatedAt` and marks the key used. A never-activated
687
+ computer is hidden from the list (only `pendingKey` shows in the connect dialog), matching
688
+ the reference product's activation lifecycle.
689
+ 5. **Update-required.** Config `minAgentVersion` (default: the plugin's major.minor) gates
690
+ tool calls: an older agent gets an actionable "upgrade this computer's agent" error, and
691
+ `/state` marks the computer `drift: true` with `updateRequired: true`.
692
+ 6. **Revoke kicks the stream.** Deleting a computer ends its live SSE stream in the same
693
+ request and invalidates the token, so a stale agent gets `401 invalid-token` and stops
694
+ retrying until re-enrolled.
package/README.md CHANGED
@@ -26,25 +26,36 @@
26
26
 
27
27
  插件是纯 JavaScript,无构建、无第三方依赖,直接从 GitHub 装:
28
28
 
29
+ **推荐从 npm 安装**(和官方插件包一样):
30
+
29
31
  ```bash
30
- # 方式 A:CLI(推荐用分支,不用 tag,见下面「更新」)
31
- dsh plugin install github:9Ashwin/dsh-computers
32
+ # 方式 A:CLI
33
+ dsh plugin install dsh-computers
34
+
35
+ # 方式 B:DSH 设置 → 插件市场 → 安装,搜 dsh-computers
36
+ ```
32
37
 
33
- # 方式 B:DSH 设置 → 插件市场 → 安装,粘贴
34
- # https://github.com/9Ashwin/dsh-computers
38
+ 也可以直接从 GitHub 装(跟随仓库最新提交):
39
+
40
+ ```bash
41
+ dsh plugin install github:9Ashwin/dsh-computers
35
42
  ```
36
43
 
37
44
  ### 更新
38
45
 
39
- GitHub 安装的插件在 profile 里是**按 spec 固定**的(`github:user/repo` 或 `github:user/repo#v1.0.2`,lockfile 会钉到具体 commit),所以:
46
+ **npm 安装**:插件市场 → 已安装 → 「检查更新」即可,或者在 CLI 里装具体版本:
40
47
 
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
+ ```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)。
48
59
 
49
60
  装好后重启 `dsh web`,设置里会出现 **电脑** 页。
50
61
 
@@ -97,20 +108,38 @@ tail -f ~/.dsh-computers/agent.log
97
108
 
98
109
  ## 3. 对话里怎么用
99
110
 
100
- 智能体会看到两个工具:
111
+ 智能体会看到这些工具(`computer` 参数都可省略,省略时用输入框左侧选中的目标电脑):
101
112
 
102
- - `computer_list` —— 列出已连接电脑(名字、平台、在线状态)
103
- - `computer_exec` —— 在指定电脑上执行命令:`{"computer": "my-mac-mini.local", "command": "sysctl -n hw.memsize"}`
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` 能力(见下)。
104
124
 
105
125
  系统提示里会自动附上当前电脑清单,所以直接说「看看两台 mac 的内存」它就会分别调用。
106
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
+
107
134
  ## 4. 端点一览
108
135
 
109
136
  | 方法 | 路径 | 说明 |
110
137
  |---|---|---|
111
138
  | POST | `/api/dsh-computers/agent/enroll` | 加入密钥换取电脑 token(runtime 卡片也走这里) |
112
139
  | GET | `/api/dsh-computers/agent/events` | 被控端的 SSE 长连接(Bearer token) |
113
- | POST | `/api/dsh-computers/agent/results` | 命令执行结果回传 |
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` | 远端文件操作结果 |
114
143
  | POST | `/api/dsh-computers/agent/heartbeat` | 心跳 |
115
144
  | GET | `/api/dsh-computers/state` | 设置页数据(角色、电脑列表,需要浏览器会话) |
116
145
  | POST | `/api/dsh-computers/keys` | 生成一次性加入密钥 |
package/agent/README.md CHANGED
@@ -21,6 +21,13 @@ The same file is used two ways, with one implementation:
21
21
  *本机运行时* card calls the module's exported `startRuntime()` in-process, so
22
22
  the user never opens a terminal (PROTOCOL §10.4).
23
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
+
24
31
  The file is served verbatim by the host plugin at
25
32
  `/api/dsh-computers/agent.mjs`, so what you run is exactly what the repository
26
33
  contains. It imports only Node built-ins, never opens a listening socket, and
@@ -70,7 +77,7 @@ node ~/.dsh-computers/agent.mjs enroll --server http://<server> --key dshc_… -
70
77
  ## Commands
71
78
 
72
79
  ```
73
- dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
80
+ dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
74
81
  dsh-computers-agent run
75
82
  dsh-computers-agent service install|uninstall|start|stop|restart|status
76
83
  dsh-computers-agent status
@@ -79,7 +86,7 @@ dsh-computers-agent --version | --help
79
86
 
80
87
  | Command | What it does |
81
88
  | --- | --- |
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. |
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. |
83
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. |
84
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. |
85
92
  | `service` | Login/auto-restart service. See below. |
@@ -155,11 +162,95 @@ Semantics guaranteed to the host:
155
162
 
156
163
  `AGENT_VERSION` is exported too (the host can put it in the §3.1 enroll body).
157
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
+
158
249
  ## Files and environment
159
250
 
160
251
  | Path | Purpose |
161
252
  | --- | --- |
162
- | `${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers}/config.json` | Credentials, mode `0600`, atomic tmp+rename |
253
+ | `${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers}/config.json` | Credentials + `workspaceBase`, mode `0600`, atomic tmp+rename |
163
254
  | `${DSH_COMPUTERS_HOME}/agent.log` | Append-only log, rotates at 5 MiB (keeps one `agent.log.1`) |
164
255
  | `~/Library/LaunchAgents/com.dsh.computers.agent.plist` | macOS service unit |
165
256
  | `~/.config/systemd/user/dsh-computers-agent.service` | Linux service unit |
@@ -178,7 +269,13 @@ Environment variables:
178
269
  - **Token**: generated by the server, stored only on this machine in a `0600`
179
270
  file, sent as `authorization: Bearer …` on every request. It is never printed,
180
271
  never logged, and `status` shows only its last 6 characters.
181
- - **Outbound only**: enroll, SSE stream, result POSTs, heartbeats. No listener.
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.
182
279
  - **Execution identity**: commands run as your user through
183
280
  `/bin/sh -lc '<command>'` (Windows: `cmd.exe /d /s /c`), inheriting your
184
281
  environment — the same power as your own terminal. Only connect your server
@@ -213,6 +310,7 @@ removed).
213
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. |
214
311
  | `✗ 需要 Node.js >= 22` | Upgrade Node; the script relies on global `fetch` and modern ESM. |
215
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. |
216
314
 
217
315
  ## Protocol notes (intentional choices)
218
316
 
@@ -273,4 +371,13 @@ paths:
273
371
  `running: false, connected: false, exitCode: 0`;
274
372
  - a foreign bare/other-JSON 401 is retried and the machine reconnects (throttled
275
373
  single warning), while JSON `invalid-token` exits 1 without retry;
276
- - 404/503 during connect are retried and the stream recovers.
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).