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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-computers contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,559 @@
1
+ # dsh-computers — implementation contract (v1)
2
+
3
+ A DSH Web plugin that turns DSH-Web on a server into a controller for the user's own
4
+ machines. Each machine runs a small outbound-only agent; the server can then run shell
5
+ commands there through a model-facing tool. One server owns the registry and dispatches;
6
+ (enroll key + one-line install + online presence + exec from the agent).
7
+
8
+ Read this file completely before writing code. Every name here is a contract between
9
+ three independently written surfaces:
10
+
11
+ | Surface | Owner scope | Files |
12
+ |---|---|---|
13
+ | Host plugin | teammate `host-dev` | `index.js`, `lib/**` |
14
+ | Machine agent | teammate `agent-dev` | `agent/**` |
15
+ | Web client (settings page) | teammate `ui-dev` | `client.js` |
16
+ | Package metadata, locales, docs, install/verify | Lead | `package.json`, `cordis.patch.yml`, `locale/**`, `icon.svg`, `README.md`, `PROTOCOL.md`, `scripts/**` |
17
+
18
+ Node 22+ (server runs Node 24 here). No third-party runtime dependencies anywhere:
19
+ host, agent and client are plain ESM / plain browser JS.
20
+
21
+ ---
22
+
23
+ ## 1. Deployment shape
24
+
25
+ ```
26
+ ┌─────────────── server ───────────────┐ ┌──────── Mac A ────────┐
27
+ │ dsh web (dsh-web-app + this plugin) │ ◀─SSE── │ dsh-computers-agent │
28
+ │ · registry (state.json) │ ──HTTP─▶│ · shell exec │
29
+ │ · SSE hub / exec dispatch │ │ · launchd service │
30
+ │ · agent tools + settings page │ └───────────────────────┘
31
+ └──────────────────────────────────────┘ ┌──────── Mac B ────────┐
32
+ ▲ browser (settings page) │ dsh-computers-agent │
33
+ └──────────────────────────────────────────┴───────────────────────┘
34
+ ```
35
+
36
+ The machine agent only makes **outbound** connections (enroll → SSE stream → result
37
+ POSTs), so no port has to be opened on the Macs and NAT is a non-issue.
38
+
39
+ The browser page talks to the host plugin over the same origin, using document-relative
40
+ URLs (`api/dsh-computers/...`, no leading slash) because the harness serves the GUI with
41
+ `<base href="./">`.
42
+
43
+ ## 2. Persisted state (host)
44
+
45
+ Path: `${DSH_HOME:-~/.dsh}/computers/state.json` (override with plugin config `stateDir`).
46
+ Written atomically (tmp file + rename), mode 0600 where the platform allows it.
47
+
48
+ ```jsonc
49
+ {
50
+ "version": 1,
51
+ "computers": [
52
+ {
53
+ "id": "cmp_9f3a...", // cmp_ + 12 lowercase hex, generated on enroll
54
+ "name": "my-mac-mini.local", // display name; defaults to hostname
55
+ "hostname": "my-mac-mini.local",
56
+ "os": "darwin", // node os.platform()
57
+ "arch": "arm64",
58
+ "platformLabel": "macOS", // macOS | Linux | Windows | Unknown
59
+ "cpuModel": "Apple M4 Pro",
60
+ "memoryBytes": 17179869184,
61
+ "nodeVersion": "v24.18.1",
62
+ "agentVersion": "1.0.0",
63
+ "tokenHash": "<sha256 hex of the agent token>",
64
+ "createdAt": "2026-11-11T10:00:00.000Z",
65
+ "lastSeenAt": "2026-11-11T10:00:05.000Z"
66
+ }
67
+ ],
68
+ "pendingKeys": [
69
+ {
70
+ "key": "dshc_<43 base64url chars>",
71
+ "createdAt": "…", "expiresAt": "…",
72
+ "os": "macos", // requested OS tab, informational only
73
+ "label": null, // optional display name for the joining machine
74
+ "usedAt": null, // set when consumed (single use)
75
+ "computerId": null // set on successful enroll
76
+ }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ Rules:
82
+ - Enrollment key: `dshc_` + `crypto.randomBytes(32).toString('base64url')`, single use,
83
+ TTL **15 min** (config `keyTtlMinutes`).
84
+ - Agent token: `dshc_` + `crypto.randomBytes(32).toString('base64url')`, long lived,
85
+ returned to the agent exactly once at enroll; the server keeps only `sha256(token)`.
86
+ - Presence: a computer is **online exactly while it holds an open SSE stream**
87
+ (`online === streams.has(id)`). The heartbeat route still refreshes `lastSeenAt` and
88
+ echoes the display name, but a fresh heartbeat never makes a stream-less computer
89
+ online: closing the stream (including a killed agent) flips it offline on the next
90
+ state read. A half-dead peer without FIN is detected when the next `ping` write fails,
91
+ so stale presence is bounded by one `heartbeatMs`, not by a separate grace window.
92
+ - Prune: a `pendingKeys` entry whose `usedAt` is set and is older than 1 h, or whose
93
+ `expiresAt` passed 1 h ago, may be dropped on the next write.
94
+
95
+ ## 3. Agent → host API
96
+
97
+ Base path **`/api/dsh-computers`**. These routes are exact `webServer.register` routes and
98
+ must NOT go through the browser request gate (they are machine-to-machine and
99
+ authenticated by token). JSON in/out, `content-type: application/json; charset=utf-8`,
100
+ `cache-control: no-store`.
101
+
102
+ Auth: `authorization: Bearer <token>` on every route except `enroll`, `join.sh`,
103
+ `agent.mjs`, `health`. Compare with `crypto.timingSafeEqual` on the sha256 digests.
104
+
105
+ ### 3.1 `POST /api/dsh-computers/agent/enroll`
106
+
107
+ Request body (max 16 KiB):
108
+
109
+ ```json
110
+ {
111
+ "key": "dshc_…",
112
+ "agentVersion": "1.0.0",
113
+ "machine": {
114
+ "hostname": "my-mac-mini.local",
115
+ "name": "my-mac-mini.local",
116
+ "os": "darwin", "arch": "arm64", "platform": "macOS",
117
+ "cpuModel": "Apple M4 Pro", "memoryBytes": 17179869184, "nodeVersion": "v24.18.1"
118
+ }
119
+ }
120
+ ```
121
+
122
+ Responses:
123
+ - `200 { "computerId": "cmp_…", "token": "dshc_…", "name": "my-mac-mini.local", "serverUrl": "http://…" }`
124
+ Creating a new computer, or re-enrolling a machine whose name/hostname matches an
125
+ existing computer **and** the key was created with `replace: true` (then the old
126
+ token hash is replaced in place, same `id`). Otherwise a new record is created.
127
+ - `401 { "error": "invalid-key", "message": "…" }` unknown key
128
+ - `410 { "error": "key-expired", "message": "…" }` past `expiresAt`
129
+ - `409 { "error": "key-used", "message": "…" }` already consumed
130
+ - `400 { "error": "bad-request", "message": "…" }`
131
+
132
+ ### 3.2 `GET /api/dsh-computers/agent/events` (SSE, auth)
133
+
134
+ Response headers: `content-type: text/event-stream; charset=utf-8`,
135
+ `cache-control: no-store`, `connection: keep-alive`, `x-accel-buffering: no`; send an
136
+ initial `retry: 2000\n\n` and flush headers immediately.
137
+
138
+ Events (SSE framing `event: <name>\ndata: <json>\n\n`):
139
+
140
+ | event | data | meaning |
141
+ |---|---|---|
142
+ | `hello` | `{ "computerId", "name", "heartbeatMs": 20000 }` | stream accepted |
143
+ | `exec` | `{ "requestId": "req_…", "command": "…", "cwd": "…or null", "timeoutMs": 120000, "shell": "sh" }` | run a command |
144
+ | `cancel` | `{ "requestId": "req_…" }` | abort a running command |
145
+ | `ping` | `{ "t": 1763000000000 }` | liveness, every `heartbeatMs` |
146
+ | `config` | `{ "name": "…", "execEnabled": true }` | optional, sent after rename |
147
+
148
+ Only one live stream per computer: a new stream for the same computer replaces the old
149
+ one (old response is ended). Closing the stream marks the computer offline.
150
+
151
+ ### 3.3 `POST /api/dsh-computers/agent/results` (auth)
152
+
153
+ ```json
154
+ {
155
+ "requestId": "req_…",
156
+ "ok": true,
157
+ "exitCode": 0,
158
+ "signal": null,
159
+ "stdout": "…",
160
+ "stderr": "…",
161
+ "durationMs": 42,
162
+ "truncated": false,
163
+ "timedOut": false,
164
+ "error": null
165
+ }
166
+ ```
167
+
168
+ → `200 { "ok": true }`; `404 { "error": "unknown-request" }` when the server already
169
+ settled/timed out that request (agent ignores it).
170
+
171
+ ### 3.4 `POST /api/dsh-computers/agent/heartbeat` (auth)
172
+
173
+ Body `{ "meta": { "cpuModel"?, "memoryBytes"?, "agentVersion"? } }` →
174
+ `200 { "ok": true, "name": "…" }` (host echoes the current display name).
175
+
176
+ ## 4. Browser → host API
177
+
178
+ Same base path. Every route here must consult the host request gate first:
179
+ `ctx.get('connection')?.requestRejection(req)` → a status number means refuse with that
180
+ status and a JSON body `{ "error": "unadmitted" }`. When the service is absent, fall back
181
+ to accepting loopback/absent-Origin the way `dshmarket/src/http.ts` documents (copy that
182
+ file's `sameOrigin` logic into `lib/http.js`; keep it small and comment the reason).
183
+
184
+ All responses JSON + `cache-control: no-store`. Errors: `{ "error": "<code>", "message": "…" }`.
185
+
186
+ ### 4.1 `GET /api/dsh-computers/state`
187
+
188
+ ```json
189
+ {
190
+ "version": 2,
191
+ "serverUrl": "https://dsh.example.com", // resolved, see §4.2
192
+ "heartbeatMs": 20000,
193
+ "computers": [
194
+ { "id": "cmp_…", "name": "…", "hostname": "…", "os": "darwin",
195
+ "platformLabel": "macOS", "online": true, "lastSeenAt": "…",
196
+ "createdAt": "…", "agentVersion": "1.0.0",
197
+ "cpuModel": "…", "memoryBytes": 17179869184,
198
+ "bots": { "used": 1, "total": 1 } }
199
+ ],
200
+ "pendingKey": { "key": "dshc_…", "expiresAt": "…", "os": "macos" } | null
201
+ }
202
+ ```
203
+
204
+ `bots` is display sugar for the "1/1 Bot" badge: `used = 1` when a computer
205
+ switches from offline → online, `total = 1`. (No per-computer bot ownership in v1; keep
206
+ the field so the UI can show the badge.)
207
+
208
+ ### 4.2 `POST /api/dsh-computers/keys`
209
+
210
+ Body `{ "os": "macos" | "windows" | "linux", "replace": false, "label": null }` → `200`
211
+
212
+ ```json
213
+ {
214
+ "key": "dshc_…",
215
+ "os": "macos",
216
+ "expiresAt": "…",
217
+ "serverUrl": "http://host:port",
218
+ "command": "curl -fsSL 'https://dsh.example.com/api/dsh-computers/join.sh?key=dshc_…' | sh",
219
+ "windowsCommand": "irm 'https://dsh.example.com/api/dsh-computers/join.ps1?key=dshc_…' | iex",
220
+ "installUrl": "https://dsh.example.com/api/dsh-computers/join.sh?key=dshc_…"
221
+ }
222
+ ```
223
+
224
+ Only one pending key at a time: creating a new one invalidates earlier pending keys
225
+ (mark them used).
226
+
227
+ `serverUrl` (here and in §4.1) is the **externally reachable base**, resolved per request by
228
+ `resolveBaseUrl({ ctx, config, req })`, first match wins:
229
+
230
+ 1. config `publicUrl` — an explicit operator override always wins. It MAY carry a path prefix for
231
+ a sub-path deployment (e.g. `https://host/dsh`); it is used verbatim, minus a trailing slash.
232
+ 2. `x-forwarded-proto` + `x-forwarded-host` (first comma entry of each; both required), or the
233
+ first element of an RFC 7239 `forwarded:` header (`proto=`/`host=`), or `x-forwarded-ssl: on`
234
+ as a protocol-only hint.
235
+ 3. the request's own `host` header plus a protocol hint: `x-forwarded-proto` / `x-forwarded-ssl`,
236
+ else the `origin` header's protocol when its host equals the resolved authority, else
237
+ `ctx.webServer.protocol`. This is what makes an HTTPS reverse proxy that forgets
238
+ `X-Forwarded-Proto` still produce `https://` — the browser's own `Origin` agrees.
239
+ 4. `ctx.webServer.getOrigin()` (wrapped — it throws before the socket binds).
240
+ 5. `http://127.0.0.1:<port>` from `ctx.webServer.port`, else `''`.
241
+
242
+ Auto-detected values are bare origins with no trailing slash. Any candidate that is not a clean
243
+ authority (a path, userinfo, whitespace, CR/LF, or over-long text) is rejected and the next
244
+ source is tried; an incomplete forwarded pair never mixes with the local protocol.
245
+
246
+ Why forwarded headers are trusted: a deployment behind a reverse proxy or tunnel is exactly the
247
+ case where the browser's own origin is the address another machine can actually reach, so the
248
+ generated one-liner is right with zero configuration (auto-detected public address).
249
+ An operator who does not trust their proxy keeps authority by setting `publicUrl`.
250
+
251
+ ### 4.3 `GET /api/dsh-computers/key-status?key=…`
252
+
253
+ ```json
254
+ { "status": "waiting" | "connected" | "expired" | "used" | "unknown",
255
+ "computer": { "id": "cmp_…", "name": "…", "online": true } | null }
256
+ ```
257
+
258
+ The connect modal polls this every 2 s and closes on `connected`.
259
+
260
+ ### 4.4 `POST /api/dsh-computers/delete`
261
+
262
+ Body `{ "id": "cmp_…" }` → `200 { "ok": true }`. Ends the live stream and drops the
263
+ record. Unknown id → `404 { "error": "not-found" }`.
264
+
265
+ ### 4.5 `POST /api/dsh-computers/rename`
266
+
267
+ Body `{ "id": "cmp_…", "name": "Desk Mac mini" }` (1–64 visible chars, trimmed) →
268
+ `200 { "ok": true, "name": "Desk Mac mini" }`. Pushes a `config` SSE event to the agent.
269
+
270
+ ### 4.6 `GET /api/dsh-computers/join.sh?key=…`
271
+
272
+ Plain-text shell installer, **no browser gate**, key-validated (unknown/expired → 410 with
273
+ a short text body). Concretely it must:
274
+
275
+ 1. `set -eu`; require `node` >= 22 (`node -v`), `curl`; print a clear error otherwise.
276
+ 2. `mkdir -p "$HOME/.dsh-computers"`.
277
+ 3. Download `"$SERVER/api/dsh-computers/agent.mjs"` → `$HOME/.dsh-computers/agent.mjs`.
278
+ 4. `node "$HOME/.dsh-computers/agent.mjs" enroll --server "$SERVER" --key "$KEY"`.
279
+ 5. `node "$HOME/.dsh-computers/agent.mjs" service install && … service start` on macOS
280
+ (launchd) and Linux (systemd --user); on failure print the manual `… run` fallback.
281
+ 6. Print a short success banner.
282
+
283
+ `SERVER` comes from the same `serverUrl` the UI shows; the script is generated by the host
284
+ (a template string in `lib/http.js` or `lib/join.js`) so it never drifts from the routes.
285
+
286
+ `GET /api/dsh-computers/join.ps1` is the Windows variant (`irm … | iex`), best effort.
287
+
288
+ ### 4.7 `GET /api/dsh-computers/agent.mjs`
289
+
290
+ Serves `agent/dsh-computers-agent.mjs` from the bundle directory
291
+ (`new URL('./agent/dsh-computers-agent.mjs', import.meta.url)`), `content-type:
292
+ text/javascript; charset=utf-8`, `cache-control: no-store`. Public.
293
+
294
+ ### 4.8 `GET /api/dsh-computers/health`
295
+
296
+ `{ "ok": true, "version": "1.0.0", "computers": <count>, "online": <count> }`.
297
+
298
+ ## 5. Model-facing tools (host plugin)
299
+
300
+ Registered with `ctx.tools.register(...)` in `apply`, `inject = ['tools', 'webServer']`
301
+ (plus optional `systemPrompt`, `connection` read via `ctx.get`).
302
+
303
+ ### 5.1 `computer_list`
304
+
305
+ - parameters: `{ "type": "object", "properties": {}, "additionalProperties": false }`
306
+ - output schema: `{ "type": "object", "properties": { "computers": { "type": "array", "items": { … } } }, "required": ["computers"] }`
307
+ - value: `{ computers: [{ id, name, online, platformLabel, os, hostname, lastSeenAt, agentVersion }] }`
308
+ - `output.render(args, value)` returns `[{ "type": "text", "text": "<markdown table or list>" }]`.
309
+
310
+ ### 5.2 `computer_exec`
311
+
312
+ - parameters:
313
+ `{ "type": "object", "properties": { "computer": {"type":"string","description":"Computer id or name from computer_list"}, "command": {"type":"string"}, "cwd": {"type":"string"}, "timeoutMs": {"type":"number"} }, "required": ["computer","command"], "additionalProperties": false }`
314
+ - Resolve `computer` by exact `id`, else case-insensitive exact `name`, else unique
315
+ substring match; ambiguity → error listing candidates.
316
+ - Dispatch through the hub, respect `exec.signal` (send `cancel` on abort, reject with a
317
+ clear "aborted" message).
318
+ - value: `{ computer: {id,name}, command, exitCode, stdout, stderr, durationMs, ok, timedOut, truncated }`
319
+ - `render` → one text block: a `$ command` line, `exit <code> · <ms>ms · <name>`, then
320
+ stdout, then stderr under a `stderr:` label. Cap the rendered text at ~20 000 chars.
321
+ - Failures (unknown computer, offline, timeout) throw an `Error` with a message the model
322
+ can act on ("computer 'x' is offline; run computer_list").
323
+
324
+ ### 5.3 System prompt section
325
+
326
+ `ctx.systemPrompt.section({ name: 'computers', order: …, text: () => … })` — a short
327
+ paragraph: "You can run shell commands on the user's connected computers with
328
+ `computer_exec`." followed by one line per computer, `- <name> (<platform>, online|offline)`.
329
+ Keep it under ~15 lines; return `''` when there are no computers. Verify the exact
330
+ `section()` argument shape with `cordis_inspect_query` (host `Service` → `systemPrompt`)
331
+ before wiring it; if it costs a request, read
332
+ `<dsh-source-checkout>/packages/core/system-prompt/src/index.ts`.
333
+
334
+ ## 6. Machine agent (`agent/dsh-computers-agent.mjs`)
335
+
336
+ Single self-contained ESM file, Node >= 22, zero dependencies, shebang `#!/usr/bin/env node`.
337
+
338
+ ```
339
+ dsh-computers-agent <command> [options]
340
+
341
+ enroll --server <url> --key <key> [--name <name>] [--replace]
342
+ run connect and serve commands in the foreground
343
+ service install|uninstall|start|stop|restart|status
344
+ status print config + whether the service is loaded
345
+ --version | --help
346
+ ```
347
+
348
+ - Config: `${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers}/config.json`
349
+ `{ "version": 1, "serverUrl": "…", "computerId": "cmp_…", "token": "dshc_…", "name": "…", "enrolledAt": "…" }`, mode 0600.
350
+ - Log: `${DSH_COMPUTERS_HOME}/agent.log`, append, rotate at 5 MiB (keep one `.1`).
351
+ - `enroll`: POST §3.1, persist config, then open the SSE stream once for ~5 s to prove the
352
+ round trip and print `✓ 已连接 <server>,这台电脑现在是 <name>` (Chinese is fine in CLI
353
+ output; keep it short). Exit non-zero with the server's message on failure.
354
+ - `run`: SSE loop (§3.2) with backoff 1 s → 30 s (jitter), re-enrolls nothing (token is
355
+ durable); on `exec` spawn the shell, stream nothing (v1 returns one result), POST §3.3;
356
+ `cancel` kills the process group; `ping` refreshes nothing (host handles presence).
357
+ Print a heartbeat log line at most every 60 s.
358
+ - Exec: POSIX `/bin/sh -lc <command>`, Windows `cmd.exe /d /s /c <command>`;
359
+ `cwd` default `os.homedir()`; env inherits; `detached: true` and kill with
360
+ `process.kill(-pid, 'SIGKILL')` on timeout/abort; cap each stream at 1 MiB, set
361
+ `truncated: true` and append `\n[output truncated]`.
362
+ - Service: macOS launchd user agent `~/Library/LaunchAgents/com.dsh.computers.agent.plist`,
363
+ `RunAtLoad` + `KeepAlive`, `StandardOutPath`/`StandardErrorPath` → the log; uses
364
+ `process.execPath` and the absolute agent path. Linux: systemd user unit at
365
+ `~/.config/systemd/user/dsh-computers-agent.service` + `systemctl --user daemon-reload`
366
+ and `enable --now` (fall back to `nohup … run &` when systemd is unavailable).
367
+ Windows: print the manual command and exit 0 with a note (v1).
368
+ - Never print the token. `status` redacts it.
369
+
370
+ ### Agent-side mock server for tests
371
+
372
+ `agent/test/mock-server.mjs`: a tiny Node HTTP server implementing §3.1–§3.4 (in-memory),
373
+ so `agent-dev` can verify enroll/SSE/exec without the real plugin. `agent/test/*.test.mjs`
374
+ must be runnable with plain `node --test agent/test/`.
375
+
376
+ ## 7. Web client (`client.js`)
377
+
378
+ Single self-contained file in the Module-Loader format (copy
379
+ `templates/decoration/client.js` from the cordis-plugin-development skill for the envelope):
380
+
381
+ ```js
382
+ window.__ModuleLoader__.load({
383
+ id: 'dsh-computers',
384
+ factory(require) { const React = require('react'); … return { inject: ['slots', 'locale'], apply(ctx) { … } } },
385
+ })
386
+ ```
387
+
388
+ - `inject: ['slots', 'locale']`.
389
+ - Register exactly one `settings.section` entry:
390
+ `ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'computers', order: 45, label: () => t('Computers') }, ComputersPanel))`.
391
+ - `ctx.locale.register('dsh-computers', { zh, en })` + `const t = ctx.locale.bind('dsh-computers')`.
392
+ All visible text goes through `t`; `locale/en.json` and `locale/zh.json` in the bundle
393
+ are for the *plugin listing* metadata (`meta.title` / `meta.description`) only.
394
+ - Page layout: title `电脑`, right-aligned primary button
395
+ `+ 连接电脑`, then a bordered list; each row: name, a `1/1 Bot` badge, right side
396
+ online/offline dot + label (`在线` / `离线`), a gear button (rename dialog) and a trash
397
+ button (delete with confirm). Empty state when no computers.
398
+ - Connect modal: title `连接电脑`, subtitle `在要连接的电脑上安装 Node.js 22+,然后在终端运行下方命令`,
399
+ three segmented tabs `Windows` / `macOS` (`Linux`), a switch row
400
+ `覆盖这台电脑已保存的连接` + hint `终端提示已有其他连接时开启,再重新复制命令`,
401
+ a read-only command box (monospace, wraps) with a copy button, a status row
402
+ `等待电脑连接…` (spinner) that polls §4.3 every 2 s, and `取消`. On connected: close
403
+ modal, refresh list, toast/inline success.
404
+ - Data: `fetch` document-relative (`api/dsh-computers/…`) with `credentials: 'same-origin'`
405
+ and `content-type: application/json` on POSTs. Poll §4.1 every 5 s while the page is
406
+ mounted (clear on unmount) so presence updates.
407
+ - Styling: only `--dsw-alias-*` theme tokens (bg-layer-1/2, border-l1/l2, label-primary/
408
+ secondary, brand-primary, state-success/error/idle-primary, bg-overlay). No hard-coded
409
+ page colors. Own CSS classes prefixed `dshc-`; inject one `<style>` element from the
410
+ component (or a module-level stylesheet inside `apply` with `ctx.effect` cleanup).
411
+ - Controls must be accessible: `role="switch"` + `aria-checked` on the replacement toggle,
412
+ real `<button>`s, Escape closes the modal, click-outside closes it, focus stays inside
413
+ while open.
414
+ - No React bits from `@deepseek-ai/dsh-client-ui-*` packages — write the controls.
415
+ - The panel receives owner props `{ close }`; a page may ignore it.
416
+
417
+ ## 8. Config (host plugin)
418
+
419
+ ```yaml
420
+ - id: dsh-computers
421
+ name: dsh-computers
422
+ config:
423
+ publicUrl: '' # e.g. https://dsh.example.com ; empty → ctx.webServer.getOrigin()
424
+ stateDir: '' # empty → $DSH_HOME/computers
425
+ keyTtlMinutes: 15
426
+ heartbeatMs: 20000
427
+ execTimeoutMs: 120000
428
+ execEnabled: true # false rejects computer_exec and tells the agent
429
+ maxOutputBytes: 1048576
430
+ ```
431
+
432
+ Validate with `export const Config` (schemastery is available to host plugins in the
433
+ profile; if importing it is fragile, validate manually in `apply` and throw a clear error).
434
+ Prefer the plain-object export form `export function apply(ctx, config)` +
435
+ `export const inject = [...]`.
436
+
437
+ ## 9. Definition of done (per surface)
438
+
439
+ - Host: `node --check` on every file; a `scripts/host-smoke.mjs` (Lead-owned harness) starts
440
+ the plugin's modules against a fake `ctx` and exercises state, keys, SSE, exec dispatch.
441
+ - Agent: `node --test agent/test/` green, including enroll → SSE → exec → result against
442
+ the mock server, timeout kill, truncation, and `--help`/`status` output.
443
+ - Client: `node --check client.js`; loads in the real page (Lead verifies with Playwright
444
+ screenshot of Settings → 电脑).
445
+ - Lead: install the bundle into the desktop profile, enroll this Mac with the real
446
+ one-liner, confirm `computer_list` + `computer_exec` from a live session, screenshot the
447
+ page, then write `README.md` (deployment + security notes) and report.
448
+
449
+ ---
450
+
451
+ ## 10. Runtime mode (v1.1): the plugin can also BE the controlled machine
452
+
453
+ Motivation: a user who installs this plugin in their **desktop** DSH instance should not
454
+ have to run a terminal command at all. Starting the desktop app (or enabling a switch in
455
+ Settings → 电脑) makes that machine a runtime and it appears on the hub's computer list.
456
+
457
+ **Topology is explicitly hub-and-spoke, and only hub-and-spoke**: exactly one server is the
458
+ controller; a runtime only ever dials out to the single hub it was enrolled against. There
459
+ is no machine-to-machine control, no mesh, no peer discovery, and a runtime never accepts
460
+ inbound connections. Any machine may however run the plugin in `role: 'both'` and be a hub
461
+ for its own machines *and* a runtime of another hub — that is two independent spokes, not a
462
+ mesh.
463
+
464
+ The standalone `agent/dsh-computers-agent.mjs` one-liner stays as the headless / always-on
465
+ fallback (launchd/systemd machines that do not run DSH), and both paths share one
466
+ implementation.
467
+
468
+ ### 10.1 Role
469
+
470
+ New plugin config `role: 'hub' | 'runtime' | 'both'` (default `'both'`).
471
+
472
+ | role | hub routes + tools + computer list | local runtime card |
473
+ |---|---|---|
474
+ | `hub` | yes | hidden |
475
+ | `runtime` | no controller routes (no `/agent/*`, `/keys`, `/key-status`, `/delete`, `/rename`, `/join.*`, no `computer_*` tools, no prompt section) — but `/state` and `/health` stay registered, because the client learns its role from `/state` | yes |
476
+ | `both` | yes | yes |
477
+
478
+ Deployments that serve others set `role: 'hub'`; a desktop install leaves the default and
479
+ gets both cards. `/api/dsh-computers/state` gains `"role": "<role>"` so the client can
480
+ decide what to render.
481
+
482
+ ### 10.2 Runtime state (local, per plugin instance)
483
+
484
+ `${stateDir}/runtime.json`, mode 0600, atomic:
485
+
486
+ ```jsonc
487
+ {
488
+ "version": 1,
489
+ "serverUrl": "https://dsh.example.com",
490
+ "computerId": "cmp_…",
491
+ "token": "dshc_…",
492
+ "name": "my-mac-mini.local",
493
+ "enabled": true,
494
+ "enrolledAt": "…",
495
+ "lastConnectedAt": "…", // optional diagnostics
496
+ "lastError": null
497
+ }
498
+ ```
499
+
500
+ `enabled` is the user's switch and survives restarts: on plugin load, if `enabled` is true
501
+ the runtime starts automatically (that is the whole point — starting DSH = starting the
502
+ runtime).
503
+
504
+ ### 10.3 Runtime manager
505
+
506
+ `lib/runtime.js` owns one instance per plugin load:
507
+
508
+ - `configure({ serverUrl, enrollmentKey })` — POST `<serverUrl>/api/dsh-computers/agent/enroll`
509
+ (the §3.1 contract, same body the CLI sends), persist §10.2 with `enabled: true`, start.
510
+ - `start()` / `stop()` / `enable(bool)` / `disconnect()` (stop + delete runtime.json).
511
+ - `status()` → `{ configured, enabled, connected, serverUrl, computerId, name, since, lastError }`.
512
+ - Reconnection, backoff, exec and result POSTs are **the agent's** code: import
513
+ `startRuntime` from `../agent/dsh-computers-agent.mjs` (see §10.4). The manager is a thin
514
+ lifecycle + persistence wrapper; it must not reimplement the wire protocol.
515
+ - Plugin unload stops the runtime (effect cleanup). Exceptions never escape into the host.
516
+
517
+ ### 10.4 Agent export contract (agent file must stay self-contained)
518
+
519
+ `agent/dsh-computers-agent.mjs` keeps working when downloaded and executed standalone, and
520
+ additionally exports:
521
+
522
+ ```js
523
+ export function startRuntime(options) // → { stop(): Promise<void>, status(): object }
524
+ ```
525
+
526
+ `options = { serverUrl, token, computerId, name?, heartbeatMs?, log?, onState?(state) }`.
527
+ It owns the SSE loop, exec, results, heartbeat. `log` is `(level, message) => void` and
528
+ defaults to console in CLI mode / the host warning sink in embedded mode. The CLI `run`
529
+ subcommand becomes a thin wrapper around it, and the module must be import-safe (CLI main
530
+ runs only when the file is the process entry point). No relative imports, no deps.
531
+
532
+ ### 10.5 Runtime routes (browser-gated, same fence as §4)
533
+
534
+ | method | path | body → response |
535
+ |---|---|---|
536
+ | GET | `/api/dsh-computers/runtime` | → §10.3 `status()` + `{ serverUrl }` |
537
+ | POST | `/api/dsh-computers/runtime/connect` | `{ serverUrl, key }` → validate URL (`http`/`https` only), call `configure`, → status. Errors: `400 bad-request` (bad URL/key shape), `401 invalid-key`, `410 key-expired`, `409 key-used` mapped from the hub, `502 hub-unreachable` with the underlying message |
538
+ | POST | `/api/dsh-computers/runtime/enable` | `{ enabled: boolean }` → status |
539
+ | POST | `/api/dsh-computers/runtime/disconnect` | → status (now unconfigured) |
540
+
541
+ All responses include the full status object so the client needs no second round trip.
542
+
543
+ ### 10.6 Client
544
+
545
+ Settings → 电脑, when `role !== 'hub'`, renders a **本机运行时** card above the computer list:
546
+
547
+ - unconfigured: 说明一行 + 服务器地址输入框 + 加入密钥输入框 + `连接` 按钮 (both fields
548
+ required; paste the key from the hub's 连接电脑 modal). Inline error on failure.
549
+ - configured: machine name + 目标服务器 + 状态点(`运行中` / `已停止` / `连接中…` / `连接失败`)
550
+ + a `启用` switch (§10.3 enable) + `断开` text button (confirm) + last error line.
551
+ - The computer list card title becomes 「已连接的电脑」 and is shown only when
552
+ `role !== 'runtime'`; both cards can be visible at once.
553
+ - Poll `/api/dsh-computers/runtime` every 5 s with the same interval as `/state`.
554
+
555
+ On the **hub** side the 连接电脑 modal keeps the one-line command and additionally exposes
556
+ the raw materials for the desktop path: below the command box, a muted line
557
+ 「也可以在这台电脑的 DSH 桌面端打开 设置 → 电脑 → 本机运行时,填入下面的地址和密钥」 plus
558
+ two compact copyable rows — the server URL and the enrollment key (`/keys` already returns
559
+ `serverUrl` and `key`). That is how a user connects a machine without a terminal.