@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1030 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Account identity, the serial number, and the hardware fingerprint (v0.7.5).
3
+ *
4
+ * ## What this is
5
+ *
6
+ * Stage 1 of `docs/ACCOUNT-FLEET-DESIGN.md`: the root of the whole scheme. Everything later —
7
+ * the device keypair, the relay's authorisation model, the fleet-scoped dispatch — depends on
8
+ * knowing *which account* a machine belongs to and *which machine* it is. Neither existed before
9
+ * this file: `machine_id` was a bare `randomUUID()` with no account and no hardware in it.
10
+ *
11
+ * ## The identity source is read, not fetched
12
+ *
13
+ * The account code comes from `ctx.deepseekAccount.getDeviceIdentity()`, whose contract is
14
+ * *"read existing login identity without creating a device or returning credentials"*. It
15
+ * returns `{ deviceId?, userId?, osVersion }`, and `userId` is the account's unique identifier.
16
+ *
17
+ * So there is **no account API call here**, and there is deliberately **no way to set the account
18
+ * code from configuration**. That second point is not a limitation to work around: a value an
19
+ * operator can type is a value an attacker can type, so a hand-entered "account code" would be a
20
+ * forgeable account code. The only accepted source is the one the host authenticates.
21
+ *
22
+ * ## Three things this file refuses to pretend
23
+ *
24
+ * **1. The serial number is not a credential.** It is computed from local inputs, so anyone who
25
+ * knows or guesses an account id can produce a well-formed one. It exists to be **displayed** and
26
+ * to be **grouped by prefix**, and the relay must never trust it. `isWellFormedSn` validates
27
+ * *shape*, and its name says shape — it is not and must not be called an authorisation check.
28
+ *
29
+ * **2. The timestamp is not what makes an SN unique.** Uniqueness is `(ACCT, HWID)`. The timestamp
30
+ * distinguishes a re-registration of the same hardware from a second machine, and makes the string
31
+ * auditable. Calling it a security feature would be an overclaim.
32
+ *
33
+ * **3. A fingerprint that changes is worse than none.** It makes one machine look like two, which
34
+ * fragments a fleet. So sources that change in normal use — MAC addresses, IPs, mount points,
35
+ * installed-package lists — are **excluded on purpose**, and each exclusion is a choice with a
36
+ * reason rather than an omission.
37
+ */
38
+
39
+ import { createHash } from 'node:crypto';
40
+ import { execFile } from 'node:child_process';
41
+ import { readFile } from 'node:fs/promises';
42
+ import { promisify } from 'node:util';
43
+
44
+ const run = promisify(execFile);
45
+
46
+ /** SN shape. `W2M-` then three dash-separated hex groups of 8, 12 and 14 characters. */
47
+ export const SN_PATTERN = /^W2M-([0-9a-f]{8})-([0-9a-f]{12})-(\d{14})$/;
48
+
49
+ /** How much of a hash goes into the account code. 8 hex = 32 bits. See the collision note below. */
50
+ export const ACCT_HEX = 8;
51
+ /** How much goes into the hardware code. 12 hex = 48 bits. */
52
+ export const HWID_HEX = 12;
53
+
54
+ /**
55
+ * Derive the account code.
56
+ *
57
+ * **Hashed, not raw.** The `userId` is an account identifier, and this code ends up in places a
58
+ * login identifier should not: device lists, log lines, chat messages, `w2m_devices` output.
59
+ * Taking a truncated hash keeps the one property grouping needs — the same account hashes the
60
+ * same way on every machine — without shipping the identifier itself around.
61
+ *
62
+ * **The cost of 32 bits, stated rather than hidden.** Two different accounts colliding would
63
+ * merge two fleets. That needs roughly 2^16 distinct accounts on one relay before it is likely,
64
+ * which is far beyond any real deployment; the design document records the detection step for the
65
+ * case where it is not.
66
+ */
67
+ export function accountCode(userId) {
68
+ if (typeof userId !== 'string' || userId.trim() === '') return null;
69
+ return createHash('sha256').update(userId.trim(), 'utf8').digest('hex').slice(0, ACCT_HEX);
70
+ }
71
+
72
+ /**
73
+ * Hash a fingerprint string into the hardware code.
74
+ *
75
+ * Normalised first — trimmed, lower-cased, and with runs of whitespace collapsed — because the
76
+ * same machine reports the same value with different spacing depending on the tool that read it,
77
+ * and a fingerprint that changes with its reader is a fingerprint that changes.
78
+ */
79
+ export function hardwareCode(...parts) {
80
+ const material = parts
81
+ .filter((p) => typeof p === 'string' && p.trim() !== '')
82
+ .map((p) => p.trim().toLowerCase().replace(/\s+/g, ' '))
83
+ .join('\n');
84
+ if (material === '') return null;
85
+ return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, HWID_HEX);
86
+ }
87
+
88
+ /** `YYYYMMDDhhmmss`, UTC. */
89
+ export function timeCode(when = new Date()) {
90
+ const d = when instanceof Date ? when : new Date(when);
91
+ if (Number.isNaN(d.getTime())) throw new TypeError('timeCode needs a valid date');
92
+ const p = (n, width = 2) => String(n).padStart(width, '0');
93
+ return (
94
+ `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
95
+ `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Build the serial number.
101
+ *
102
+ * `W2M-<ACCT>-<HWID>-<TS>`. Refuses to produce a partial one: an SN missing its account code
103
+ * would be a machine that claims no fleet, and silently emitting that is how a machine ends up
104
+ * in the wrong pool.
105
+ */
106
+ export function buildSn({ accountCode: acct, hardwareCode: hwid, at = new Date() } = {}) {
107
+ if (typeof acct !== 'string' || !/^[0-9a-f]{8}$/.test(acct)) {
108
+ throw new TypeError('buildSn needs an 8-hex account code; without one a machine claims no fleet');
109
+ }
110
+ if (typeof hwid !== 'string' || !/^[0-9a-f]{12}$/.test(hwid)) {
111
+ throw new TypeError('buildSn needs a 12-hex hardware code');
112
+ }
113
+ return `W2M-${acct}-${hwid}-${timeCode(at)}`;
114
+ }
115
+
116
+ /**
117
+ * Does this string have the shape of an SN?
118
+ *
119
+ * **Shape, not authorisation.** The name is deliberate: this answers "is it well formed", and a
120
+ * caller that treats a `true` here as proof of anything is misusing it. Nothing about an SN is
121
+ * verifiable by inspection, because it was computed by the party being checked.
122
+ */
123
+ export function isWellFormedSn(value) {
124
+ return typeof value === 'string' && SN_PATTERN.test(value);
125
+ }
126
+
127
+ /** The parts of an SN, or null if it is not well formed. */
128
+ export function parseSn(value) {
129
+ const m = typeof value === 'string' ? SN_PATTERN.exec(value) : null;
130
+ if (m === null) return null;
131
+ return { accountCode: m[1], hardwareCode: m[2], timeCode: m[3] };
132
+ }
133
+
134
+ /** The account prefix an SN belongs to, for grouping. Null when the SN is malformed. */
135
+ export function fleetOf(value) {
136
+ const parts = parseSn(value);
137
+ return parts === null ? null : parts.accountCode;
138
+ }
139
+
140
+ /**
141
+ * Hardware sources, per platform, in priority order.
142
+ *
143
+ * Each entry says how to read a stable machine identity. `key` names the field in the returned
144
+ * diagnostic so an operator can see *which* source was used — a fingerprint whose origin is
145
+ * unknown is one nobody can debug when it changes unexpectedly.
146
+ *
147
+ * **What is deliberately absent, and why:**
148
+ * * **MAC addresses / IPs** — change with a cable, a dock, a VPN, or a Wi-Fi toggle. A machine
149
+ * that re-fingerprints after a network change becomes a second device in the fleet.
150
+ * * **Mount points / partition layout** — change when a disk is added or a volume is resized.
151
+ * * **Installed packages / versions** — change on every update, so the fingerprint would churn.
152
+ * * **Hostname alone** — user-editable in one command on every platform.
153
+ */
154
+ export const HW_SOURCES = Object.freeze({
155
+ win32: Object.freeze([
156
+ Object.freeze({ key: 'MachineGuid', kind: 'registry', path: 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', name: 'MachineGuid' }),
157
+ Object.freeze({ key: 'SMBIOS-UUID', kind: 'exec', file: 'powershell', args: Object.freeze(['-NoProfile', '-Command', '(Get-CimInstance Win32_ComputerSystemProduct).UUID']) }),
158
+ ]),
159
+ darwin: Object.freeze([
160
+ Object.freeze({ key: 'IOPlatformUUID', kind: 'exec', file: 'ioreg', args: Object.freeze(['-rd1', '-c', 'IOPlatformExpertDevice']), extract: /"IOPlatformUUID"\s*=\s*"([^"]+)"/ }),
161
+ ]),
162
+ linux: Object.freeze([
163
+ Object.freeze({ key: 'machine-id', kind: 'file', path: '/etc/machine-id' }),
164
+ Object.freeze({ key: 'dbus-machine-id', kind: 'file', path: '/var/lib/dbus/machine-id' }),
165
+ ]),
166
+ });
167
+
168
+ /**
169
+ * Values that mean "this source gave nothing useful".
170
+ *
171
+ * A machine with no SMBIOS UUID reports all-zeroes or a placeholder depending on firmware, and
172
+ * hashing that would give **every such machine the same hardware code** — a far worse failure
173
+ * than having none, because it silently merges distinct machines.
174
+ */
175
+ const USELESS = new Set([
176
+ '',
177
+ '0',
178
+ 'none',
179
+ 'null',
180
+ 'undefined',
181
+ '00000000-0000-0000-0000-000000000000',
182
+ 'ffffffff-ffff-ffff-ffff-ffffffffffff',
183
+ '03000200-0400-0500-0006-000700080009',
184
+ 'to be filled by o.e.m.',
185
+ 'default string',
186
+ 'system serial number',
187
+ ]);
188
+
189
+ /** Is this a value worth hashing? */
190
+ export function isUsefulHardwareValue(value) {
191
+ if (typeof value !== 'string') return false;
192
+ const v = value.trim().toLowerCase();
193
+ return v !== '' && !USELESS.has(v);
194
+ }
195
+
196
+ /**
197
+ * Read the raw hardware values, in priority order, and report which were usable.
198
+ *
199
+ * Everything is injectable so a test can drive a Windows-shaped machine on Linux, or a Darwin
200
+ * fixture in CI, without the host having that platform's tools — the alternative is a suite that
201
+ * only exercises whichever platform it happens to run on.
202
+ *
203
+ * @param {object} [options]
204
+ * @param {string} [options.platform]
205
+ * @param {(spec: object) => Promise<string|null>} [options.read] Default reader; override wholly in tests.
206
+ * @param {(file: string, args: string[]) => Promise<string>} [options.exec]
207
+ * @param {(path: string) => Promise<string>} [options.readFile]
208
+ * @param {(event: string, detail?: object) => void} [options.audit]
209
+ */
210
+ export async function readHardwareValues(options = {}) {
211
+ const platform = options.platform ?? process.platform;
212
+ const sources = HW_SOURCES[platform] ?? [];
213
+ const execImpl = options.exec ?? (async (file, args) => {
214
+ const { stdout } = await run(file, args, { windowsHide: true, timeout: 5000 });
215
+ return stdout;
216
+ });
217
+ const readFileImpl = options.readFile ?? ((path) => readFile(path, 'utf8'));
218
+ const audit = options.audit ?? (() => {});
219
+
220
+ const readOne = options.read ?? (async (spec) => {
221
+ if (spec.kind === 'file') return readFileImpl(spec.path);
222
+ if (spec.kind === 'exec') {
223
+ const out = await execImpl(spec.file, spec.args);
224
+ if (spec.extract) {
225
+ const m = spec.extract.exec(out);
226
+ return m === null ? null : m[1];
227
+ }
228
+ return out;
229
+ }
230
+ if (spec.kind === 'registry') {
231
+ // `reg query` is used rather than a native call so there is no dependency beyond Windows
232
+ // itself, and so the same `exec` seam covers every source.
233
+ const out = await execImpl('reg', ['query', spec.path, '/v', spec.name]);
234
+ const m = /REG_SZ\s+(\S+)/.exec(out);
235
+ return m === null ? null : m[1];
236
+ }
237
+ return null;
238
+ });
239
+
240
+ const values = [];
241
+ for (const spec of sources) {
242
+ let raw = null;
243
+ try {
244
+ raw = await readOne(spec);
245
+ } catch (error) {
246
+ // A source that cannot be read is not a failure of the fingerprint: the next source is the
247
+ // point of having a list. It is recorded so an operator can see the list was cut short.
248
+ audit('hw/source-failed', { source: spec.key, detail: error instanceof Error ? error.message : String(error) });
249
+ continue;
250
+ }
251
+ const value = typeof raw === 'string' ? raw.trim() : '';
252
+ if (!isUsefulHardwareValue(value)) {
253
+ audit('hw/source-unusable', { source: spec.key, value: value.slice(0, 40) });
254
+ continue;
255
+ }
256
+ values.push({ source: spec.key, value });
257
+ }
258
+ return { platform, values };
259
+ }
260
+
261
+ /**
262
+ * The host-facing identity service.
263
+ *
264
+ * `getDeviceIdentity()` is read off the context rather than imported, because it is a Cordis
265
+ * service and an optional one: a host without it must produce a named refusal, not a crash. That
266
+ * is what `ctx.get('deepseekAccount')` is for.
267
+ *
268
+ * @param {object} options
269
+ * @param {object} [options.ctx] A Cordis context exposing `get('deepseekAccount')`.
270
+ * @param {object} [options.account] The service itself, injected directly in tests.
271
+ * @param {string} [options.platform]
272
+ * @param {object} [options.hardware] Overrides for `readHardwareValues`.
273
+ * @param {object} [options.stored] An SN already on disk, so it is never regenerated.
274
+ * @param {(event: string, detail?: object) => void} [options.audit]
275
+ */
276
+ export function createAccountIdentity(options = {}) {
277
+ const audit = options.audit ?? (() => {});
278
+ const platform = options.platform ?? process.platform;
279
+
280
+ function accountService() {
281
+ if (options.account) return options.account;
282
+ if (typeof options.ctx?.get === 'function') return options.ctx.get('deepseekAccount') ?? null;
283
+ return null;
284
+ }
285
+
286
+ /**
287
+ * The account's own identifier, or a named reason there is none.
288
+ *
289
+ * `signed-out` is a **normal state**, not an error: a machine with no account has no fleet, and
290
+ * the design says it must not be given a random one instead. Fabricating a placeholder here
291
+ * would put two unrelated machines in the same pool, which is the exact defect being removed.
292
+ */
293
+ async function identify() {
294
+ const service = accountService();
295
+ if (service === null || typeof service.getDeviceIdentity !== 'function') {
296
+ return { ok: false, reason: 'no_account_service', detail: 'the host exposes no deepseekAccount service' };
297
+ }
298
+ let identity;
299
+ try {
300
+ identity = await service.getDeviceIdentity();
301
+ } catch (error) {
302
+ return { ok: false, reason: 'identity_read_failed', detail: error instanceof Error ? error.message : String(error) };
303
+ }
304
+ const userId = typeof identity?.userId === 'string' && identity.userId.trim() !== '' ? identity.userId : null;
305
+ if (userId === null) {
306
+ return { ok: false, reason: 'signed-out', detail: 'no DeepSeek account is signed in on this machine' };
307
+ }
308
+ const acct = accountCode(userId);
309
+ if (acct === null) return { ok: false, reason: 'bad_account_id', detail: 'the account id hashed to nothing' };
310
+ audit('account/identified', { account_code: acct });
311
+ return { ok: true, accountCode: acct, deviceId: identity.deviceId ?? null, osVersion: identity.osVersion ?? null };
312
+ }
313
+
314
+ /** The hardware code, and which source produced it. */
315
+ async function fingerprint() {
316
+ const { values } = await readHardwareValues({ platform, ...options.hardware });
317
+ if (values.length === 0) {
318
+ return { ok: false, reason: 'no_hardware_source', detail: `no usable hardware identity on ${platform}` };
319
+ }
320
+ const hwid = hardwareCode(...values.map((v) => v.value));
321
+ if (hwid === null) return { ok: false, reason: 'no_hardware_source', detail: 'the values hashed to nothing' };
322
+ return { ok: true, hardwareCode: hwid, sources: values.map((v) => v.source) };
323
+ }
324
+
325
+ /**
326
+ * The identity this machine should present.
327
+ *
328
+ * A stored SN is returned unchanged, always. Regenerating it would change the machine's identity
329
+ * on every start — every restart would look like a new device, and the fleet would fill with
330
+ * ghosts of one machine.
331
+ */
332
+ async function resolve() {
333
+ if (options.stored !== undefined && options.stored !== null) {
334
+ const parsed = parseSn(options.stored);
335
+ if (parsed === null) {
336
+ return { ok: false, reason: 'stored_sn_malformed', detail: options.stored };
337
+ }
338
+ return { ok: true, sn: options.stored, ...parsed, reused: true };
339
+ }
340
+ const account = await identify();
341
+ if (!account.ok) return account;
342
+ const hardware = await fingerprint();
343
+ if (!hardware.ok) return hardware;
344
+ const sn = buildSn({ accountCode: account.accountCode, hardwareCode: hardware.hardwareCode, at: options.at ?? new Date() });
345
+ audit('account/sn-created', { sn, sources: hardware.sources });
346
+ return {
347
+ ok: true,
348
+ sn,
349
+ accountCode: account.accountCode,
350
+ hardwareCode: hardware.hardwareCode,
351
+ timeCode: parseSn(sn).timeCode,
352
+ hardwareSources: hardware.sources,
353
+ reused: false,
354
+ };
355
+ }
356
+
357
+ return { identify, fingerprint, resolve };
358
+ }
359
+
360
+ /** The config keys an SN needs in order to survive a restart. */
361
+ export const SN_STATE_KEY = 'sn';
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The action set the chat bridge calls, and how a fleet result becomes a sentence (v0.7.2).
3
+ *
4
+ * ## What this file is for
5
+ *
6
+ * `bridge.mjs` takes an `actions` object and hands it to `commands.mjs`. Until now nothing
7
+ * produced one for the real fleet, so `/run` could only ever answer "this install has no fleet".
8
+ * This is that producer: it takes the executor from `fleet.mjs` and the DSH-facing operations
9
+ * the host supplies, and returns something a command handler can call.
10
+ *
11
+ * ## The formatting rule
12
+ *
13
+ * A chat reply is read on a phone, once, and often while walking. So a verdict is never
14
+ * paraphrased into "done" or "failed" — the relay's own word is shown, with the machine names
15
+ * that produced it, because `divergent-platform` and `divergent` mean different things and
16
+ * collapsing them turns a platform difference into a bug report. Long output is truncated at a
17
+ * line boundary with the omission **stated**, never silently cut.
18
+ *
19
+ * ## What is injected and what is real
20
+ *
21
+ * The fleet half is real: it talks to the relay. The session half (`listSessions`, `bind`,
22
+ * `tail`, `prompt`, `setPolicy`) is the DSH host's, because only the host knows its sessions —
23
+ * so it is injected, and a host that supplies none gets named refusals rather than silence.
24
+ */
25
+
26
+ import { CAPABILITY } from './commands.mjs';
27
+
28
+ /** How much of a task's output is worth putting on a phone. */
29
+ export const DEFAULT_TAIL_LINES = 24;
30
+
31
+ /** Truncate at a line boundary and say so. A silent cut reads as "that was all of it". */
32
+ export function tailLines(text, limit = DEFAULT_TAIL_LINES) {
33
+ const lines = String(text ?? '').split('\n');
34
+ if (lines.length <= limit) return lines.join('\n');
35
+ const kept = lines.slice(-limit);
36
+ return [`…(省略前 ${lines.length - limit} 行)`, ...kept].join('\n');
37
+ }
38
+
39
+ /**
40
+ * A one-line-per-machine summary, using the field names a live relay actually returns.
41
+ *
42
+ * Measured on a report: each row carries `machine_name`, `outcome` (the per-machine verdict),
43
+ * `status` (what the process did — `nonzero_exit`, `ok`), `lease_state`, and `exit_code`. The
44
+ * first version of this read `machine`/`verdict`, which are both `undefined` in a real response,
45
+ * so every machine would have printed as `?: ?` while the task completed perfectly.
46
+ */
47
+ export function describeMachines(body) {
48
+ const rows = [];
49
+ for (const key of ['machines', 'leases', 'results']) {
50
+ if (Array.isArray(body?.[key])) {
51
+ rows.push(...body[key]);
52
+ break;
53
+ }
54
+ }
55
+ if (rows.length === 0 && body && typeof body === 'object' && !Array.isArray(body)) {
56
+ // A map keyed by machine name, which some shapes use. An empty object is not a map of zero
57
+ // machines and must not be reported as one — `[]` and "no per-machine shape at all" are the
58
+ // same answer, and the empty case is the one an unstarted task produces.
59
+ const entries = Object.entries(body).filter(([, v]) => v && typeof v === 'object');
60
+ if (entries.length > 0 && entries.every(([, v]) => 'verdict' in v || 'status' in v || 'outcome' in v)) {
61
+ rows.push(...entries.map(([name, value]) => ({ machine_name: name, ...value })));
62
+ }
63
+ }
64
+ return rows.map((entry) => {
65
+ const name = entry.machine_name ?? entry.machine ?? entry.name ?? entry.machine_id ?? '?';
66
+ // The per-machine verdict is `outcome`; `status` is what the process did, which is a
67
+ // different fact and is shown alongside rather than instead of it.
68
+ const parts = [`${name}: ${entry.outcome ?? entry.verdict ?? entry.lease_state ?? '?'}`];
69
+ if (entry.status !== undefined && entry.status !== entry.outcome) parts.push(`status ${entry.status}`);
70
+ if (entry.exit_code !== undefined && entry.exit_code !== null) parts.push(`exit ${entry.exit_code}`);
71
+ return parts.join(' ');
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Turn a completed wait into the sentence that goes to the phone.
77
+ *
78
+ * Kept separate from the executor so it is testable without a relay, and so the wording lives
79
+ * in one place instead of being assembled at three call sites.
80
+ */
81
+ export function formatRunResult({ taskId, wait, report = null, argv = [] }) {
82
+ if (!wait?.ok) return `派发失败:${wait?.text ?? wait?.reason ?? '未知原因'}`;
83
+ const verdict = wait.verdict ?? 'unknown';
84
+ const lines = [`任务 ${String(taskId).slice(0, 8)} — ${verdict}`];
85
+ if (argv.length > 0) lines.push(`命令:${argv.join(' ')}`);
86
+
87
+ const machines = describeMachines(wait.body) ?? [];
88
+ if (machines.length > 0) lines.push(...machines);
89
+
90
+ if (!wait.terminal) {
91
+ // Said plainly, because "no verdict" and "a verdict of timeout" are different and the
92
+ // operator can act on only one of them.
93
+ lines.push(`(等满 ${Math.round((wait.maxMs ?? 0) / 1000)}s 仍未结束,任务还在跑)`);
94
+ }
95
+ if (report) {
96
+ const failures = (report.failed ?? []).slice(0, 5);
97
+ if (failures.length > 0) {
98
+ lines.push('失败机器:');
99
+ for (const f of failures) lines.push(` ${f.machine ?? f.machine_name ?? '?'}: ${f.reason ?? f.detail ?? '?'}`);
100
+ }
101
+ }
102
+ const output = report?.stdout ?? wait.body?.stdout ?? null;
103
+ if (typeof output === 'string' && output.trim() !== '') {
104
+ lines.push('输出:', tailLines(output));
105
+ }
106
+ return lines.join('\n');
107
+ }
108
+
109
+ /**
110
+ * Build the action set.
111
+ *
112
+ * @param {object} options
113
+ * @param {object} options.executor From `createFleetExecutor`.
114
+ * @param {object} [options.host] The DSH-facing half: listSessions, boundSession, bind, tail,
115
+ * prompt, setPolicy. Anything absent becomes a named refusal.
116
+ * @param {object} [options.policy] From `policy.mjs`, for /trust and /untrust.
117
+ * @param {number} [options.waitMs] How long /run will hold the phone before answering.
118
+ * @param {boolean} [options.autoWait] Whether /run waits at all, or only dispatches.
119
+ * @param {(event: string, detail?: object) => void} [options.audit]
120
+ */
121
+ export function createChatopsActions(options) {
122
+ const executor = options.executor;
123
+ if (!executor) throw new TypeError('createChatopsActions requires an executor');
124
+ const host = options.host ?? {};
125
+ const policy = options.policy ?? null;
126
+ const waitMs = options.waitMs ?? 90_000;
127
+ const autoWait = options.autoWait !== false;
128
+ const audit = options.audit ?? (() => {});
129
+
130
+ /** A host operation that is absent, named rather than silently returning nothing. */
131
+ const missing = (what) => () => {
132
+ throw new Error(`这个安装没有接入 ${what}`);
133
+ };
134
+
135
+ const actions = {
136
+ // ---- the DSH half: injected, because only the host knows its sessions ----
137
+ listSessions: typeof host.listSessions === 'function' ? host.listSessions : missing('会话列表'),
138
+ boundSession: typeof host.boundSession === 'function' ? host.boundSession : async () => null,
139
+ bind: typeof host.bind === 'function' ? host.bind : missing('会话绑定'),
140
+ tail: typeof host.tail === 'function' ? host.tail : missing('会话输出'),
141
+ prompt: typeof host.prompt === 'function' ? host.prompt : undefined,
142
+ setPolicy: typeof host.setPolicy === 'function' ? host.setPolicy : missing('策略设置'),
143
+
144
+ // ---- the fleet half: real, and the reason this release exists ----
145
+
146
+ /** Machines, mapped onto the shape `/machines` prints. */
147
+ async listMachines() {
148
+ const result = await executor.devices();
149
+ if (!result.ok) throw new Error(result.text ?? result.reason);
150
+ return result.devices.map((device) => ({
151
+ id: device.machine_id ?? device.id ?? '?',
152
+ name: device.name ?? device.machine_name ?? '?',
153
+ os: device.platform ?? device.os ?? null,
154
+ arch: device.arch ?? null,
155
+ online: device.online !== false && device.state !== 'stale',
156
+ }));
157
+ },
158
+
159
+ /**
160
+ * `/run` — dispatch, then either wait or say where to look.
161
+ *
162
+ * The argv is passed through untouched. It is **not** joined into a shell string anywhere:
163
+ * a chat message is attacker-influenced text, and the whole reason this fleet takes an argv
164
+ * array is that no shell ever sees it.
165
+ */
166
+ async dispatch(argv) {
167
+ const dispatched = await executor.run(argv);
168
+ if (!dispatched.ok) return { taskId: null, summary: dispatched.text };
169
+ audit('chatops/run-dispatched', { task_id: dispatched.taskId, argv });
170
+
171
+ if (!autoWait) {
172
+ return {
173
+ taskId: dispatched.taskId,
174
+ summary: `已派发:${argv.join(' ')}\n任务 ${String(dispatched.taskId).slice(0, 8)},用 /report ${String(dispatched.taskId).slice(0, 8)} 查看结果。`,
175
+ };
176
+ }
177
+
178
+ const waited = await executor.wait(dispatched.taskId, { maxMs: waitMs });
179
+ let report = null;
180
+ if (waited.ok && waited.terminal) {
181
+ const fetched = await executor.report(dispatched.taskId);
182
+ if (fetched.ok) report = fetched.raw;
183
+ }
184
+ return {
185
+ taskId: dispatched.taskId,
186
+ summary: formatRunResult({ taskId: dispatched.taskId, wait: { ...waited, maxMs: waitMs }, report, argv }),
187
+ };
188
+ },
189
+
190
+ /** `/where` — which machine answered, and with what. */
191
+ async where(taskId) {
192
+ const read = await executor.status(taskId);
193
+ if (!read.ok) return { text: read.text ?? read.reason };
194
+ const body = read.raw ?? {};
195
+ const verdict = body.verdict ?? body.status ?? 'unknown';
196
+ const lines = [`任务 ${String(taskId).slice(0, 8)} — ${verdict}`];
197
+ const machines = describeMachines(body);
198
+ if (machines.length > 0) lines.push(...machines);
199
+ else lines.push('(还没有机器回报)');
200
+ return { text: lines.join('\n') };
201
+ },
202
+
203
+ /** `/report` — the full per-machine report, truncated for a phone. */
204
+ async fullReport(taskId) {
205
+ const fetched = await executor.report(taskId);
206
+ if (!fetched.ok) return { text: fetched.text ?? fetched.reason };
207
+ const body = fetched.raw ?? {};
208
+ const lines = [`任务 ${String(taskId).slice(0, 8)} 报告`];
209
+ const verdict = body.verdict ?? body.status ?? null;
210
+ if (verdict) lines.push(`结论:${verdict}`);
211
+ const machines = describeMachines(body);
212
+ if (machines.length > 0) lines.push(...machines);
213
+ const output = body.stdout ?? body.output ?? null;
214
+ if (typeof output === 'string' && output.trim() !== '') lines.push('输出:', tailLines(output));
215
+ return { text: lines.join('\n') };
216
+ },
217
+
218
+ // ---- trust, delegated to policy so there is one source of truth ----
219
+ trust: policy ? (channel, id) => policy.trust(channel, id) : () => ({ ok: false, reason: 'no policy is wired' }),
220
+ distrust: policy ? (channel, id) => policy.distrust(channel, id) : () => ({ ok: false, reason: 'no policy is wired' }),
221
+ };
222
+
223
+ return actions;
224
+ }
225
+
226
+ /** Capabilities a chat identity holds once admitted. Re-exported so the host has one import. */
227
+ export const OWNER = CAPABILITY;