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/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
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.
|
|
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.
|