@twinsearth/w2m-dsh-plugin 0.6.2 → 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.
- package/CHANGELOG.md +1057 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +33 -22
- package/src/chatops/account.mjs +361 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +429 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- 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;
|