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/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.
|