@twinsearth/w2m-dsh-plugin 0.47.0 → 0.49.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 +117 -0
- package/package.json +1 -1
- package/src/chatops/ilink-fleet-bridge.mjs +123 -4
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,123 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.49.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**Every message now gets an answer. Five inputs were being silently dropped, and two of the three input
|
|
10
|
+
types the request asked me to advertise are not supported.**
|
|
11
|
+
|
|
12
|
+
### What was silent, measured before it was changed
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
文字 /run node --version → dispatch
|
|
16
|
+
文字 /run --ask 你好 → reply
|
|
17
|
+
文字 你好 → ignore why=not a /run line
|
|
18
|
+
图片 (type 2) → ignore why=not a text message
|
|
19
|
+
语音 (type 3) → ignore why=not a text message
|
|
20
|
+
空 item_list → ignore why=not a text message
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Five inputs, no answer.** A bot that reads messages and replies to none of them is
|
|
24
|
+
**indistinguishable from a bot that is broken**, and the operator's only move was to send the command again.
|
|
25
|
+
|
|
26
|
+
### The one silence that survived, and why it had to
|
|
27
|
+
|
|
28
|
+
`no sender` and `no context_token` still return `ignore`. **With no sender or no context token there is
|
|
29
|
+
nowhere to send an answer**, so returning `reply` would be a plan that lies about what the caller can do.
|
|
30
|
+
**That is the reason the vocabulary still contains `ignore` at all** — collapsing it into `reply` would have
|
|
31
|
+
made every plan look successful while nothing was sent.
|
|
32
|
+
|
|
33
|
+
### The request asked the help to advertise "text, voice and images". I refused two of the three.
|
|
34
|
+
|
|
35
|
+
`textOf` reads only `item type: 1` — **measured, not inferred: an image and a voice note both answer
|
|
36
|
+
`not a text message`.** So the help says:
|
|
37
|
+
|
|
38
|
+
> 支持:文字。中文可以。
|
|
39
|
+
> 不支持:语音、图片、文件、表情——它们只会得到这条帮助。
|
|
40
|
+
|
|
41
|
+
**A help text that promised a capability the bridge does not have would be read as a bug the first time
|
|
42
|
+
somebody sent a photo**, and the bug report would be about the bridge rather than about the promise.
|
|
43
|
+
|
|
44
|
+
### The three sections are asserted by heading
|
|
45
|
+
|
|
46
|
+
`【1】开关指令` · `【2】指令规则与格式` · `【3】参考与边界` — asserted by name rather than by "the text mentions
|
|
47
|
+
commands", because **a help text that loses a section still reads fine.**
|
|
48
|
+
|
|
49
|
+
Two of the seven new assertions are worth naming:
|
|
50
|
+
|
|
51
|
+
* **`--ask` is in the help.** It was reachable only by reading the source, which is the entire
|
|
52
|
+
discoverability defect this section exists to close. If it ever leaves the help, the defect is back.
|
|
53
|
+
* **every `/run` example in the help is put through the real parser.** The help cannot drift into
|
|
54
|
+
advertising syntax that is refused.
|
|
55
|
+
|
|
56
|
+
### The activation instruction, answered honestly
|
|
57
|
+
|
|
58
|
+
*"If refused, provide the activation instruction"* — and the honest answer is that **a chat window cannot
|
|
59
|
+
activate itself**. The help says the capabilities are granted by whoever starts the bridge, through
|
|
60
|
+
`W2M_CAPABILITIES`, and that a refusal names the missing capability. **Implying otherwise would have sent
|
|
61
|
+
the operator looking for a command that does not exist.**
|
|
62
|
+
|
|
63
|
+
**Three existing tests demanded `ignore` and now demand `reply`.** They were right about the rule — a typo
|
|
64
|
+
must never become a fleet execution — and wrong about how the rule is expressed. They were updated rather
|
|
65
|
+
than deleted, because **the old expectation is the record of what changed.**
|
|
66
|
+
|
|
67
|
+
**7 new tests, 0 failures.**
|
|
68
|
+
|
|
69
|
+
## [0.48.0] — 2026-10-15
|
|
70
|
+
|
|
71
|
+
**`--dry-run` dispatched. Two dry runs sent a command to five machines, and one made a real task.**
|
|
72
|
+
|
|
73
|
+
### The defect, measured before it was described
|
|
74
|
+
|
|
75
|
+
The bridge guarded exactly one thing under `--dry-run`: the **reply**. The dispatch ran unconditionally.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
[15:13:57] dry run : true
|
|
79
|
+
[15:15:00] dispatched task 01M50231AYJYDD41WSPMS9PRM3
|
|
80
|
+
[15:15:50] verdict for …: partial
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**That is a real task record with five real leases, produced by a run whose flag promised nothing would
|
|
84
|
+
happen.** A rehearsal executed commands on production machines — and it was found by accident, while draining
|
|
85
|
+
a message queue, not by a test.
|
|
86
|
+
|
|
87
|
+
### Why the fix is "skip the dispatch" and not "rename the flag"
|
|
88
|
+
|
|
89
|
+
**This repository already fixes the meaning of the word.** `update-install`'s `dryRun` is asserted to **call no
|
|
90
|
+
package manager and write nothing**, and `auto-update`'s is asserted to **write nothing to the profile**. So a
|
|
91
|
+
second `dry-run` meaning "do the dangerous part anyway" is not a naming preference — **it is two flags with one
|
|
92
|
+
name and opposite meanings, and the bridge held the unsafe one.**
|
|
93
|
+
|
|
94
|
+
**Renaming would have left the flag. Aligning keeps the word and removes the hazard**, and a reader who knows
|
|
95
|
+
`dryRun` from anywhere else in this project is now already right about this one.
|
|
96
|
+
|
|
97
|
+
### Why the guard is a function in `lib`, not an `if` in the script
|
|
98
|
+
|
|
99
|
+
`scripts/` is **not in `package.json#files`**, so nothing there is covered by a test that ships. A safety
|
|
100
|
+
property expressed as one line inside an untested script is a property nobody re-checks. `dryRunPolicy` is
|
|
101
|
+
exported and asserted beside the wire shapes — **a script is not where a guard should live if the guard is the
|
|
102
|
+
point.**
|
|
103
|
+
|
|
104
|
+
The guard sits at the **top of the only function that reaches `fleet.run`**, not at its call site, because a
|
|
105
|
+
guard at the call site is one edit away from being bypassed by a second caller.
|
|
106
|
+
|
|
107
|
+
### A name collision that made my own test wrong
|
|
108
|
+
|
|
109
|
+
I asserted that `dryRun: 'true'` must not dispatch. **It does dispatch — the code was right and the test was
|
|
110
|
+
wrong**, and the cause is two different things sharing one word:
|
|
111
|
+
|
|
112
|
+
| | meaning |
|
|
113
|
+
|---|---|
|
|
114
|
+
| the CLI **flag** `--dry-run` | **present-means-true** (`argv.includes('--dry-run')`) |
|
|
115
|
+
| the **parameter** `dryRun` | **defaults to false**, which is what `update-install` means by it |
|
|
116
|
+
|
|
117
|
+
`dryRunPolicy` takes the parameter, and the script always hands it a boolean. The assertion is now the strict
|
|
118
|
+
one — **only `true` turns the dispatch off, so a future caller cannot open the path with a truthy value.**
|
|
119
|
+
|
|
120
|
+
**5 new tests, 0 failures.** One of them reads the script and asserts the guard appears **before** the only
|
|
121
|
+
call that can reach the fleet — because a test of the pure function alone would pass while the script still
|
|
122
|
+
dispatched, **which is precisely the shape of the original defect.**
|
|
123
|
+
|
|
7
124
|
## [0.47.0] — 2026-10-15
|
|
8
125
|
|
|
9
126
|
**Correcting my own counts: 0.46.0 said 54/32/22, the table says 61/53/8.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -25,6 +25,47 @@
|
|
|
25
25
|
|
|
26
26
|
import { parseInvocation, renderVerdict, renderDispatchFailure } from './run-command.mjs';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* What a run with `--dry-run` set is allowed to do.
|
|
30
|
+
*
|
|
31
|
+
* ## The defect this exists to make unreachable
|
|
32
|
+
*
|
|
33
|
+
* `scripts/ilink-fleet-bridge.mjs` guarded exactly one thing under `--dry-run`: the **reply**. The
|
|
34
|
+
* dispatch itself ran unconditionally, so **two dry runs sent `node--version` to all five machines**
|
|
35
|
+
* — and one of them produced a real task record with five real leases. The flag's name promises that
|
|
36
|
+
* nothing happens, and the promise was false in the direction that matters: **a rehearsal executed
|
|
37
|
+
* commands on production machines.**
|
|
38
|
+
*
|
|
39
|
+
* ## Why "skip the dispatch" rather than "rename the flag"
|
|
40
|
+
*
|
|
41
|
+
* This repository already fixes the meaning of the word. `update-install`'s `dryRun` is asserted to
|
|
42
|
+
* **write nothing and call no package manager**, and `auto-update`'s is asserted to **write nothing to
|
|
43
|
+
* the profile**. So a second `dry-run` that means "do the dangerous part anyway" is not a naming
|
|
44
|
+
* preference — it is two flags with one name and opposite meanings, and the bridge held the unsafe one.
|
|
45
|
+
*
|
|
46
|
+
* **Renaming would have left the flag. Aligning keeps the word and removes the hazard**, and it means
|
|
47
|
+
* a reader who knows `dryRun` from anywhere else in this project is already right about this one.
|
|
48
|
+
*
|
|
49
|
+
* ## Why this is a function rather than an `if` in the script
|
|
50
|
+
*
|
|
51
|
+
* `scripts/` is not in `package.json#files`, so nothing there is covered by a test that ships. A safety
|
|
52
|
+
* property expressed as one line inside an untested script is a property nobody re-checks. As an
|
|
53
|
+
* exported function with a name, it is asserted in `chatops-ilink-fleet-bridge.test.mjs` beside the
|
|
54
|
+
* wire shapes — **a script is not where a guard should live if the guard is the point.**
|
|
55
|
+
*
|
|
56
|
+
* @param {{ dryRun?: boolean }} [options]
|
|
57
|
+
* @returns {{ dispatch: boolean, reply: boolean, reason: string|null }}
|
|
58
|
+
*/
|
|
59
|
+
export function dryRunPolicy(options = {}) {
|
|
60
|
+
if (options.dryRun !== true) return { dispatch: true, reply: true, reason: null };
|
|
61
|
+
return {
|
|
62
|
+
dispatch: false,
|
|
63
|
+
reply: false,
|
|
64
|
+
// Carried so the caller logs *why* nothing happened rather than printing a bare "[dry-run]".
|
|
65
|
+
reason: 'dry run: the fleet was not dispatched to, and no reply was sent',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
/** The header every authenticated call needs; undocumented and mandatory. */
|
|
29
70
|
export const AUTH_TYPE = 'ilink_bot_token';
|
|
30
71
|
export const APP_ID = 'bot';
|
|
@@ -99,6 +140,64 @@ export function sendSucceeded(response) {
|
|
|
99
140
|
return response.ret === 0 || response.errcode === 0;
|
|
100
141
|
}
|
|
101
142
|
|
|
143
|
+
/**
|
|
144
|
+
* What to say when a message is not a command.
|
|
145
|
+
*
|
|
146
|
+
* ## The requirement this satisfies, and the part of it that had to be refused
|
|
147
|
+
*
|
|
148
|
+
* *"Every sentence from WeChat must get a reply; a mistake must get help."* Before this, **five measured
|
|
149
|
+
* inputs were silently dropped**: a plain sentence, an image, a voice note, an empty `item_list`, and an
|
|
150
|
+
* empty string. The operator sees a bot that reads messages and answers nothing, which is
|
|
151
|
+
* indistinguishable from a bot that is broken.
|
|
152
|
+
*
|
|
153
|
+
* The request also asked the help to advertise *"text, voice and images"*. **Two of those three are not
|
|
154
|
+
* supported**, and `textOf` reading only `type: 1` is the proof: an image and a voice note both answer
|
|
155
|
+
* `not a text message`. So the boundary section says what is true — **a help text that promises a
|
|
156
|
+
* capability the bridge does not have would be read as a bug the first time somebody sent a photo.**
|
|
157
|
+
*
|
|
158
|
+
* ## Why the boundary is a section rather than a footnote
|
|
159
|
+
*
|
|
160
|
+
* A user cannot discover a limitation they are never told about. **"Voice is not supported" arriving in
|
|
161
|
+
* the same breath as the command syntax is the difference between a limitation and a mystery.**
|
|
162
|
+
*
|
|
163
|
+
* @param {{reason?: string|null}} [context]
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
export function helpText(context = {}) {
|
|
167
|
+
const why = typeof context.reason === 'string' && context.reason !== '' ? context.reason : null;
|
|
168
|
+
const lines = [];
|
|
169
|
+
if (why !== null) lines.push(`没有执行这条消息:${why}`, '');
|
|
170
|
+
|
|
171
|
+
lines.push(
|
|
172
|
+
'【1】开关指令',
|
|
173
|
+
' /run <命令> 把一个命令派发到编队(默认所有在线机器)',
|
|
174
|
+
' /run --ask <一句话> 问一句话,不执行任何东西',
|
|
175
|
+
' /run --on <机器> <命令> 只派发到指定机器(逗号分隔)',
|
|
176
|
+
' /run --timeout <毫秒> <命令> 覆盖等待上限',
|
|
177
|
+
' /run --mode <模式> <命令> replicate | split | broadcast | pipeline',
|
|
178
|
+
'',
|
|
179
|
+
' —— 关于权限:这个窗口当前的能力是 operate、fleet.dispatch。',
|
|
180
|
+
' 若被拒绝,回复里会写明缺哪一个能力;能力由启动桥接的人通过',
|
|
181
|
+
' W2M_CAPABILITIES 授予,聊天窗口本身不能自行激活。',
|
|
182
|
+
'',
|
|
183
|
+
'【2】指令规则与格式',
|
|
184
|
+
' · 只能以 /run 开头,其余文字一律当作普通消息并回这条帮助。',
|
|
185
|
+
' · /run 之后是 argv(逐个词),不是 shell 字符串:',
|
|
186
|
+
' 管道 | 、重定向 > 、&& 都不会生效,会被拒绝。',
|
|
187
|
+
' · 每个词里的空格要用引号包起来。',
|
|
188
|
+
' · 举例: /run node --version',
|
|
189
|
+
' /run git rev-parse --short HEAD',
|
|
190
|
+
' /run --ask 现在编队里有几台机器在线?',
|
|
191
|
+
'',
|
|
192
|
+
'【3】参考与边界',
|
|
193
|
+
' · 支持:文字。中文可以。',
|
|
194
|
+
' · 不支持:语音、图片、文件、表情——它们只会得到这条帮助。',
|
|
195
|
+
' · 命令是否被允许由目标机器自己的允许清单决定;',
|
|
196
|
+
' 被拒绝时回复会带上原因码(如 COMMAND_NOT_ALLOWED)。',
|
|
197
|
+
);
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
102
201
|
/**
|
|
103
202
|
* Decide what to do with one inbound message.
|
|
104
203
|
*
|
|
@@ -117,14 +216,34 @@ export function planMessage({ message, authorize }) {
|
|
|
117
216
|
const contextToken = message?.context_token;
|
|
118
217
|
const text = textOf(message);
|
|
119
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The only two real silences, and both are structural rather than a decision.
|
|
221
|
+
*
|
|
222
|
+
* With no `from_user_id` or no `context_token` there is nowhere to send an answer to — so a `reply`
|
|
223
|
+
* here would be a promise the caller cannot keep. **These stay `ignore`, and they are the reason the
|
|
224
|
+
* vocabulary still has one**: collapsing them into `reply` would make the plan lie about what the
|
|
225
|
+
* script can do. Everything below this line gets an answer, because it can.
|
|
226
|
+
*/
|
|
120
227
|
if (typeof from !== 'string' || from === '') return { act: 'ignore', why: 'no sender' };
|
|
121
228
|
if (typeof contextToken !== 'string' || contextToken === '') return { act: 'ignore', why: 'no context_token' };
|
|
122
|
-
|
|
229
|
+
|
|
230
|
+
// A non-text message is answered rather than dropped: "every sentence must get a reply" includes the
|
|
231
|
+
// sentences the bridge cannot read.
|
|
232
|
+
if (text === null) {
|
|
233
|
+
return { act: 'reply', to: from, contextToken, text: helpText({ reason: '这条消息不是文字(语音/图片/文件暂不支持)' }) };
|
|
234
|
+
}
|
|
123
235
|
|
|
124
236
|
const trimmed = text.trim();
|
|
125
|
-
// Only the exact `/run` prefix is an action
|
|
126
|
-
//
|
|
127
|
-
if (!/^\/run(\s|$)/i.test(trimmed))
|
|
237
|
+
// Only the exact `/run` prefix is an action, and that rule is unchanged: a typo must never become a
|
|
238
|
+
// fleet execution. What changed is that it now gets an answer instead of silence.
|
|
239
|
+
if (!/^\/run(\s|$)/i.test(trimmed)) {
|
|
240
|
+
return {
|
|
241
|
+
act: 'reply',
|
|
242
|
+
to: from,
|
|
243
|
+
contextToken,
|
|
244
|
+
text: helpText({ reason: trimmed === '' ? '空消息' : '这条消息没有以 /run 开头' }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
128
247
|
|
|
129
248
|
const body = trimmed.replace(/^\/run\s*/i, '');
|
|
130
249
|
const invocation = parseInvocation(body);
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
|
|
|
208
208
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
209
209
|
* overrides it at runtime for tests and packagers.
|
|
210
210
|
*/
|
|
211
|
-
const PLUGIN_VERSION = '0.
|
|
211
|
+
const PLUGIN_VERSION = '0.49.0';
|
|
212
212
|
|
|
213
213
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
214
214
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|