@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,409 @@
1
+ /**
2
+ * Turning a chat message into an action (v0.7.0).
3
+ *
4
+ * ## What this replaces
5
+ *
6
+ * `dsh-chatops` 0.1.0 kept its eleven commands in a twelve-arm `switch` inside a 1,060-line
7
+ * class that also held the session catalogue, the approval broker, the file delivery and
8
+ * the vendor card probes. Three consequences, all of which this file is shaped to avoid:
9
+ *
10
+ * * **authorisation was a property of the caller, not the command.** Every command was
11
+ * reachable by every trusted window, so `/sessions`, `/use`, `/policy 2` and `/approve`
12
+ * sat behind one undifferentiated check. Here each command declares the capability it
13
+ * needs, and the router refuses by name when the caller lacks it — which is also what
14
+ * makes "who can do what" answerable without reading the implementations.
15
+ * * **the surface was undiscoverable.** `/help` was a hand-maintained string that could
16
+ * drift from the `switch`. Here the table *is* the help, so a command that exists is
17
+ * documented and a command that is documented exists.
18
+ * * **nothing was testable in isolation.** A command handler now receives a plain object
19
+ * of injected actions and returns a plain result. No host, no clock, no network.
20
+ *
21
+ * ## The fleet commands
22
+ *
23
+ * A chat bridge for this project has a reason to exist beyond the plugin it was ported
24
+ * from: `w2m` already runs commands on every machine. So the router exposes the fleet —
25
+ * `machines` to see it, `run` to use it, `where` to ask which machine a task ran on — and
26
+ * those are the commands that make a phone a usable console rather than a notification
27
+ * feed. They are also the reason `run` needs `fleet.dispatch`: dispatching is the one
28
+ * action here with effects on other machines, and it is gated separately from everything
29
+ * that merely reads.
30
+ */
31
+
32
+ /** Capabilities a command may require. A caller holds a set of these. */
33
+ export const CAPABILITY = Object.freeze({
34
+ /** Nothing beyond being an admitted identity. */
35
+ VIEW: 'view',
36
+ /** Ask a session to do work, or bind a window to one. */
37
+ OPERATE: 'operate',
38
+ /** Read a session's transcript, which can contain anything the model has seen. */
39
+ TRANSCRIPT: 'transcript',
40
+ /** Move a file out of the project. */
41
+ FILES: 'files',
42
+ /** Answer an approval, including one for a privileged operation. */
43
+ APPROVE: 'approve',
44
+ /** Change a session's sandbox policy. */
45
+ POLICY: 'policy',
46
+ /** Dispatch a command to the fleet. */
47
+ FLEET_DISPATCH: 'fleet.dispatch',
48
+ /** Change who is trusted. The one capability that can widen all the others. */
49
+ ADMIN: 'admin',
50
+ });
51
+
52
+ /** Which capabilities a freshly admitted owner holds. */
53
+ export const OWNER_CAPABILITIES = Object.freeze([
54
+ CAPABILITY.VIEW,
55
+ CAPABILITY.OPERATE,
56
+ CAPABILITY.TRANSCRIPT,
57
+ CAPABILITY.FILES,
58
+ CAPABILITY.APPROVE,
59
+ CAPABILITY.POLICY,
60
+ CAPABILITY.FLEET_DISPATCH,
61
+ CAPABILITY.ADMIN,
62
+ ]);
63
+
64
+ /** Why a command was not run. Returned as a code, so a test can assert on it. */
65
+ export const ROUTE_REFUSAL = Object.freeze({
66
+ /** Not a command — the caller wants to send it as a prompt. */
67
+ NOT_A_COMMAND: 'not_a_command',
68
+ /** A `/name` that no command claims. */
69
+ UNKNOWN_COMMAND: 'unknown_command',
70
+ /** The command needs a capability the caller does not hold. */
71
+ FORBIDDEN: 'forbidden',
72
+ /** The arguments do not fit the command. */
73
+ BAD_ARGUMENTS: 'bad_arguments',
74
+ /** The command exists but this install has not wired its action. */
75
+ UNAVAILABLE: 'unavailable',
76
+ });
77
+
78
+ /**
79
+ * The command table.
80
+ *
81
+ * Each entry: `name`, `usage`, `summary`, `capability`, and `run(args, ctx)`. Keeping the
82
+ * help text in the entry is what stops it drifting from the implementation.
83
+ */
84
+ export function commandTable() {
85
+ return [
86
+ {
87
+ name: 'help',
88
+ usage: '/help [command]',
89
+ summary: 'List the commands, or explain one.',
90
+ capability: CAPABILITY.VIEW,
91
+ run(args, ctx) {
92
+ if (args.length === 0) {
93
+ const lines = ctx.commands.map((c) => `${c.usage} — ${c.summary}`);
94
+ return { text: [`可用指令:`, ...lines.map((l) => ` ${l}`)].join('\n') };
95
+ }
96
+ const wanted = args[0].replace(/^\//, '');
97
+ const command = ctx.commands.find((c) => c.name === wanted);
98
+ if (!command) return { text: `没有名为 ${wanted} 的指令。` };
99
+ return { text: `${command.usage}\n${command.summary}\n所需权限:${command.capability}` };
100
+ },
101
+ },
102
+ {
103
+ name: 'whoami',
104
+ usage: '/whoami',
105
+ summary: 'Show the identity this window is bound to and what it may do.',
106
+ capability: CAPABILITY.VIEW,
107
+ run(_args, ctx) {
108
+ return {
109
+ text: [
110
+ `身份:${ctx.identity.channel}:${ctx.identity.id}`,
111
+ `窗口:${ctx.identity.window}`,
112
+ `权限:${[...ctx.capabilities].sort().join(', ') || '(无)'}`,
113
+ ].join('\n'),
114
+ };
115
+ },
116
+ },
117
+ {
118
+ name: 'sessions',
119
+ usage: '/sessions',
120
+ summary: 'List the sessions this window could act on.',
121
+ capability: CAPABILITY.VIEW,
122
+ async run(_args, ctx) {
123
+ const sessions = await ctx.actions.listSessions();
124
+ if (sessions.length === 0) return { text: '没有任何会话。' };
125
+ const bound = await ctx.actions.boundSession(ctx.identity.window);
126
+ const lines = sessions.map((s, i) => {
127
+ const marker = s.id === bound ? '*' : ' ';
128
+ return `${marker}${i + 1}. ${s.title || s.id} (${s.id.slice(0, 8)})`;
129
+ });
130
+ return { text: ['会话(* 为当前绑定):', ...lines].join('\n') };
131
+ },
132
+ },
133
+ {
134
+ name: 'use',
135
+ usage: '/use <序号|会话 id 前缀>',
136
+ summary: 'Bind this window to a session.',
137
+ capability: CAPABILITY.OPERATE,
138
+ async run(args, ctx) {
139
+ if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/use <序号|会话 id 前缀>' };
140
+ const sessions = await ctx.actions.listSessions();
141
+ const key = args[0];
142
+ let target = null;
143
+ if (/^\d+$/.test(key)) {
144
+ target = sessions[Number(key) - 1] ?? null;
145
+ } else {
146
+ // Prefix matching is deliberately strict about ambiguity: binding the wrong
147
+ // session because two share a prefix is a silent misroute, and the caller cannot
148
+ // tell it happened.
149
+ const matches = sessions.filter((s) => s.id.startsWith(key));
150
+ if (matches.length > 1) {
151
+ return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `${key} 匹配到 ${matches.length} 个会话,请写更长的前缀。` };
152
+ }
153
+ target = matches[0] ?? null;
154
+ }
155
+ if (!target) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `找不到会话 ${key}。` };
156
+ await ctx.actions.bind(ctx.identity.window, target.id);
157
+ return { text: `已绑定到:${target.title || target.id}` };
158
+ },
159
+ },
160
+ {
161
+ name: 'log',
162
+ usage: '/log [行数]',
163
+ summary: 'Show the tail of the bound session.',
164
+ capability: CAPABILITY.TRANSCRIPT,
165
+ async run(args, ctx) {
166
+ const limit = args.length === 0 ? 20 : Number(args[0]);
167
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
168
+ return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '行数需为 1..200。' };
169
+ }
170
+ const sessionId = await ctx.actions.boundSession(ctx.identity.window);
171
+ if (!sessionId) return { text: '这个窗口还没绑定会话,先 /use。' };
172
+ const lines = await ctx.actions.tail(sessionId, limit);
173
+ return { text: lines.length === 0 ? '(没有输出)' : lines.join('\n') };
174
+ },
175
+ },
176
+ {
177
+ name: 'machines',
178
+ usage: '/machines',
179
+ summary: 'List the machines the fleet currently holds.',
180
+ capability: CAPABILITY.VIEW,
181
+ async run(_args, ctx) {
182
+ if (!ctx.actions.listMachines) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
183
+ const machines = await ctx.actions.listMachines();
184
+ if (machines.length === 0) return { text: '编队里没有在线机器。' };
185
+ const lines = machines.map((m) => `${m.online ? '●' : '○'} ${m.name} ${m.os ?? '?'}/${m.arch ?? '?'} ${m.id.slice(0, 8)}`);
186
+ return { text: ['编队:', ...lines].join('\n') };
187
+ },
188
+ },
189
+ {
190
+ name: 'run',
191
+ usage: '/run <命令…>',
192
+ summary: 'Run a command on the whole fleet and report the verdict.',
193
+ capability: CAPABILITY.FLEET_DISPATCH,
194
+ async run(args, ctx) {
195
+ if (args.length === 0) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/run <命令…>' };
196
+ if (!ctx.actions.dispatch) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
197
+ const outcome = await ctx.actions.dispatch(args);
198
+ return { text: outcome.summary, taskId: outcome.taskId };
199
+ },
200
+ },
201
+ {
202
+ name: 'where',
203
+ usage: '/where <任务 id>',
204
+ summary: 'Show how a task is doing, machine by machine.',
205
+ capability: CAPABILITY.VIEW,
206
+ async run(args, ctx) {
207
+ if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/where <任务 id>' };
208
+ if (!ctx.actions.where) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
209
+ const outcome = await ctx.actions.where(args[0]);
210
+ return { text: outcome.text };
211
+ },
212
+ },
213
+ {
214
+ name: 'report',
215
+ usage: '/report <任务 id>',
216
+ summary: 'Show a task\'s full per-machine report.',
217
+ capability: CAPABILITY.VIEW,
218
+ async run(args, ctx) {
219
+ if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/report <任务 id>' };
220
+ if (!ctx.actions.fullReport) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
221
+ const outcome = await ctx.actions.fullReport(args[0]);
222
+ return { text: outcome.text };
223
+ },
224
+ },
225
+ {
226
+ name: 'approve',
227
+ usage: '/approve [审批 id]',
228
+ summary: 'Approve the oldest pending request for this window.',
229
+ capability: CAPABILITY.APPROVE,
230
+ async run(args, ctx) {
231
+ return decideApproval(args, ctx, 'allowed-once');
232
+ },
233
+ },
234
+ {
235
+ name: 'reject',
236
+ usage: '/reject [审批 id]',
237
+ summary: 'Reject the oldest pending request for this window.',
238
+ capability: CAPABILITY.APPROVE,
239
+ async run(args, ctx) {
240
+ return decideApproval(args, ctx, 'rejected');
241
+ },
242
+ },
243
+ {
244
+ name: 'pending',
245
+ usage: '/pending',
246
+ summary: 'Show what this window is being asked to approve.',
247
+ capability: CAPABILITY.APPROVE,
248
+ run(_args, ctx) {
249
+ const waiting = ctx.actions.pending(ctx.identity.window);
250
+ if (waiting.length === 0) return { text: '没有待审批的请求。' };
251
+ const lines = waiting.map(
252
+ (p) => `${p.approval_id.slice(0, 8)} ${p.tool} ${Math.ceil(p.remaining_ms / 1000)}s 后超时${p.reason ? ` (${p.reason})` : ''}`,
253
+ );
254
+ return { text: ['待审批:', ...lines].join('\n') };
255
+ },
256
+ },
257
+ {
258
+ name: 'policy',
259
+ usage: '/policy <0|1|2>',
260
+ summary: 'Set the bound session\'s sandbox policy. 0 read-only, 1 ask, 2 danger-full-access.',
261
+ capability: CAPABILITY.POLICY,
262
+ async run(args, ctx) {
263
+ if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/policy <0|1|2>' };
264
+ // Numeric index, never a string: a policy name taken from chat text would let the
265
+ // caller choose an arbitrary value if the enum ever grows a member the model should
266
+ // not be able to select.
267
+ const mode = ctx.policyModes[Number(args[0])];
268
+ if (!mode) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '策略需为 0、1 或 2。' };
269
+ const sessionId = await ctx.actions.boundSession(ctx.identity.window);
270
+ if (!sessionId) return { text: '这个窗口还没绑定会话,先 /use。' };
271
+ await ctx.actions.setPolicy(sessionId, mode);
272
+ return { text: `已把 ${sessionId.slice(0, 8)} 的策略设为 ${mode}。` };
273
+ },
274
+ },
275
+ {
276
+ name: 'trust',
277
+ usage: '/trust <channel:id>',
278
+ summary: 'Trust an identity. Widens access, so it needs admin.',
279
+ capability: CAPABILITY.ADMIN,
280
+ async run(args, ctx) {
281
+ if (args.length !== 1 || !args[0].includes(':')) {
282
+ return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/trust <channel:id>,例如 feishu:ou_abc' };
283
+ }
284
+ const cut = args[0].indexOf(':');
285
+ const channel = args[0].slice(0, cut);
286
+ const id = args[0].slice(cut + 1);
287
+ const result = ctx.actions.trust(channel, id);
288
+ return result.ok ? { text: `已信任 ${channel}:${id}。` } : { text: `未变更:${result.reason}` };
289
+ },
290
+ },
291
+ {
292
+ name: 'untrust',
293
+ usage: '/untrust <channel:id>',
294
+ summary: 'Revoke an identity. Needs admin.',
295
+ capability: CAPABILITY.ADMIN,
296
+ async run(args, ctx) {
297
+ if (args.length !== 1 || !args[0].includes(':')) {
298
+ return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/untrust <channel:id>' };
299
+ }
300
+ const cut = args[0].indexOf(':');
301
+ const channel = args[0].slice(0, cut);
302
+ const id = args[0].slice(cut + 1);
303
+ const result = ctx.actions.distrust(channel, id);
304
+ return result.ok ? { text: `已撤销 ${channel}:${id}。` } : { text: `未变更:${result.reason}` };
305
+ },
306
+ },
307
+ ];
308
+ }
309
+
310
+ /** Shared by `/approve` and `/reject`, so the two cannot drift apart. */
311
+ async function decideApproval(args, ctx, outcome) {
312
+ const waiting = ctx.actions.pending(ctx.identity.window);
313
+ if (waiting.length === 0) return { text: '没有待审批的请求。' };
314
+ // An explicit id is honoured only if it belongs to *this* window: an id from another
315
+ // conversation would otherwise let a trusted identity answer a request it was never
316
+ // shown, which is the attribution problem in miniature.
317
+ let target;
318
+ if (args.length === 1) {
319
+ target = waiting.find((p) => p.approval_id.startsWith(args[0]));
320
+ if (!target) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '这个窗口没有匹配的待审批请求。' };
321
+ } else {
322
+ target = waiting[0];
323
+ }
324
+ const result = ctx.actions.decideApproval(target.approval_id, outcome);
325
+ if (!result.ok) {
326
+ // Reported as a failure rather than a cheerful confirmation. 0.1.0 replied
327
+ // "✅ 已批准,继续执行" on the strength of a promise resolving, with nothing tying it to
328
+ // the request it was supposedly authorising.
329
+ return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `未生效:${result.reason}` };
330
+ }
331
+ return { text: outcome === 'allowed-once' ? '已批准。' : '已拒绝。' };
332
+ }
333
+
334
+ /**
335
+ * Parse one inbound message.
336
+ *
337
+ * @param {string} text
338
+ * @returns {{kind: 'command', name: string, args: string[]} | {kind: 'prompt', text: string}}
339
+ * A leading `/` that names nothing is **not** a prompt: sending `/sesions` to the
340
+ * model because it was misspelled is how a typo becomes unexpected model work.
341
+ */
342
+ export function parseCommand(text) {
343
+ if (typeof text !== 'string') return { kind: 'prompt', text: '' };
344
+ const trimmed = text.trim();
345
+ if (!trimmed.startsWith('/')) return { kind: 'prompt', text: trimmed };
346
+ const [head, ...args] = trimmed.split(/\s+/);
347
+ return { kind: 'command', name: head.slice(1).toLowerCase(), args };
348
+ }
349
+
350
+ /**
351
+ * Route one message.
352
+ *
353
+ * @param {string} text
354
+ * @param {object} ctx
355
+ * @param {Set<string>} ctx.capabilities
356
+ * @param {{channel: string, id: string, window: string}} ctx.identity
357
+ * @param {object} ctx.actions
358
+ * @param {Array} [ctx.commands] Defaults to the full table.
359
+ * @returns {Promise<{kind: string, name?: string, text?: string, taskId?: string, refusal?: string}>}
360
+ */
361
+ export async function route(text, ctx) {
362
+ const parsed = parseCommand(text);
363
+ if (parsed.kind === 'prompt') return { kind: 'prompt', text: parsed.text };
364
+
365
+ const commands = ctx.commands ?? commandTable();
366
+ const command = commands.find((c) => c.name === parsed.name);
367
+ if (!command) {
368
+ return {
369
+ kind: 'refused',
370
+ name: parsed.name,
371
+ refusal: ROUTE_REFUSAL.UNKNOWN_COMMAND,
372
+ text: `未知指令 /${parsed.name}。发送 /help 查看可用指令。`,
373
+ };
374
+ }
375
+ // Authorisation is checked before the handler runs, never inside it, so a handler
376
+ // cannot accidentally become reachable by forgetting its own check.
377
+ if (!ctx.capabilities?.has(command.capability)) {
378
+ return {
379
+ kind: 'refused',
380
+ name: command.name,
381
+ refusal: ROUTE_REFUSAL.FORBIDDEN,
382
+ text: `/${command.name} 需要 ${command.capability} 权限,当前身份没有。`,
383
+ };
384
+ }
385
+
386
+ const full = { ...ctx, commands };
387
+ let result;
388
+ try {
389
+ result = await command.run(parsed.args, full);
390
+ } catch (error) {
391
+ // A handler that throws is a defect, and it is reported as one rather than as a refusal
392
+ // the operator might mistake for a permission decision.
393
+ return {
394
+ kind: 'error',
395
+ name: command.name,
396
+ text: `/${command.name} 执行失败:${error instanceof Error ? error.message : String(error)}`,
397
+ };
398
+ }
399
+
400
+ if (result?.refusal) {
401
+ return { kind: 'refused', name: command.name, refusal: result.refusal, text: result.text };
402
+ }
403
+ return { kind: 'ok', name: command.name, text: result?.text ?? '', taskId: result?.taskId };
404
+ }
405
+
406
+ /** The help text, generated from the table so it cannot drift. */
407
+ export function helpText(commands = commandTable()) {
408
+ return commands.map((c) => `${c.usage.padEnd(28)} ${c.summary}`).join('\n');
409
+ }