@twinsearth/w2m-dsh-plugin 0.58.0 → 0.60.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 +128 -0
- package/TROUBLESHOOTING.md +53 -0
- package/bin/w2m-localside.mjs +32 -9
- package/bin/w2m-rabbit.mjs +183 -168
- package/bin/w2m-stun.mjs +82 -67
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/chatops/ilink-fleet-bridge.mjs +1 -1
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,134 @@ 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.60.0] — 2026-10-16
|
|
8
|
+
|
|
9
|
+
**Two things learned from a client that would not start, neither of which was a crash in this plugin.**
|
|
10
|
+
|
|
11
|
+
### §3.1 in `TROUBLESHOOTING.md`: check the lock before you check the plugin
|
|
12
|
+
|
|
13
|
+
A launch that leaves **no process at all** is a different failure from one that starts and reports something, and
|
|
14
|
+
the existing §3 procedure will mislead anyone who skips that distinction — it assumes the application *ran*.
|
|
15
|
+
|
|
16
|
+
**The mechanism is one function in `@deepseek-ai/dsh-desktop`'s `lib/main.js`:**
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
function claimDesktopSingleInstance(application, focusOwner) {
|
|
20
|
+
if (!application.requestSingleInstanceLock()) {
|
|
21
|
+
application.quit(); // no dialog, no error, no window
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**A second launch is silently discarded while the first instance holds the lock.** Electron registers it against
|
|
27
|
+
the *application*, not a directory — which is why the two natural remedies both fail, and why the section now says
|
|
28
|
+
so before offering any plugin diagnosis:
|
|
29
|
+
|
|
30
|
+
| What was tried | Why it did nothing |
|
|
31
|
+
|---|---|
|
|
32
|
+
| disable third-party plugins before restarting | the new process quits at the lock, before plugin loading |
|
|
33
|
+
| reinstall the client (new folder, new exe) | a new directory does not release an application-scoped lock |
|
|
34
|
+
|
|
35
|
+
**Deleting `~/.dsh` fixed it — not because anything in it was corrupt, but because the deletion took down the
|
|
36
|
+
process still holding the lock.** The section now leads with the measurement that costs nothing:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
Get-Process -Name 'DeepSeek Harness' | Select-Object Id, StartTime
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Processes listed → it is the lock, end them and retry. Nothing listed → the failure really is pre-lock, and §3's
|
|
43
|
+
procedure applies.** The deleted directory carried session history and profile configuration; **`Stop-Process`
|
|
44
|
+
would have cost nothing.**
|
|
45
|
+
|
|
46
|
+
### `scripts/session-retention.mjs`: the store is bounded, out-of-band, as DSH says it must be
|
|
47
|
+
|
|
48
|
+
`@deepseek-ai/dsh-session-persistence`'s README, under *Known Limitations*:
|
|
49
|
+
|
|
50
|
+
> **No deletion or retention API** — pruning stored sessions is **out-of-band backend maintenance**.
|
|
51
|
+
|
|
52
|
+
**So this is not a workaround for a missing feature: the host states retention is the operator's job.** The
|
|
53
|
+
measurement that motivated it is that **one session reached 18.03 MiB of an 18.07 MiB store**, nothing bounds it,
|
|
54
|
+
and every start reads the store.
|
|
55
|
+
|
|
56
|
+
Three refusals are built in, and each is a deliberate limit rather than an omission:
|
|
57
|
+
|
|
58
|
+
* **The newest session of every workspace is never removed.** DSH writes to it, and *which* process is writing
|
|
59
|
+
cannot be checked reliably from outside — so the rule is the one that needs no knowledge of the running
|
|
60
|
+
process.
|
|
61
|
+
* **`--apply` is required.** The default prints the plan and exits 0. **A tool that removes conversation history
|
|
62
|
+
shows what it will remove before it removes it.**
|
|
63
|
+
* **A directory that is not a session is reported, not touched**, because the layout is read from the filesystem
|
|
64
|
+
rather than assumed.
|
|
65
|
+
|
|
66
|
+
**Verified against the real store:** at a 256 MiB cap it reports *"under the cap — nothing to remove"*; at a
|
|
67
|
+
1 MiB cap it plans two removals and leaves the 18 MiB live session alone.
|
|
68
|
+
|
|
69
|
+
**No test count change** — both additions are a document and a script, and the script is exercised by hand against
|
|
70
|
+
the store it bounds rather than by a fixture that would not resemble it.
|
|
71
|
+
|
|
72
|
+
## [0.59.0] — 2026-10-16
|
|
73
|
+
|
|
74
|
+
**Every published bin can now be imported without killing the importing process — measured, not reasoned about.**
|
|
75
|
+
|
|
76
|
+
### The defect
|
|
77
|
+
|
|
78
|
+
`package.json` publishes three CLIs as importable `exports` subpaths:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
"./rabbit": "./bin/w2m-rabbit.mjs"
|
|
82
|
+
"./localside": "./bin/w2m-localside.mjs"
|
|
83
|
+
"./stun": "./bin/w2m-stun.mjs"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**And those three had no import guard, so their top-level body ran on import.** Measured:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
import .../bin/w2m-localside.mjs -> node exited with code 2
|
|
90
|
+
a statement placed after the import never ran
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**A published entry point that terminates whichever process imports it.** The plugin's own client-bundle
|
|
94
|
+
documentation records that `exports['./client']` *"is what the host scans for"*, so the host reads that map —
|
|
95
|
+
**and `w2m-mcp.mjs` already carried this guard, which is why it was the one bin that behaved.**
|
|
96
|
+
|
|
97
|
+
The fix gives the other three the same guard rather than withdrawing the exports: **a subpath that is importable
|
|
98
|
+
and inert is strictly better than one that is neither.** All four are now inert on import (`58ms / 74ms / 5ms /
|
|
99
|
+
2ms`) and all four still run as programs (`--help` → exit 0 with the right banner).
|
|
100
|
+
|
|
101
|
+
### What this does **not** claim, and why that matters here
|
|
102
|
+
|
|
103
|
+
**This is not offered as the explanation for the client that would not start.** That was reported as *no process
|
|
104
|
+
at all on a double-click*, and that symptom has a different mechanism, read out of `lib/main.js`:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
function claimDesktopSingleInstance(application, focusOwner) {
|
|
108
|
+
if (!application.requestSingleInstanceLock()) {
|
|
109
|
+
application.quit();
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**`application.quit()` — no dialog, no error, no window.** A second launch is silently discarded while the first
|
|
115
|
+
instance holds the lock, which explains all three observations: disabling third-party plugins did nothing (the
|
|
116
|
+
new process never reached plugin loading), **reinstalling the client did nothing (a new directory does not release
|
|
117
|
+
a lock registered against the application)**, and deleting `~/.dsh` worked — **because it took down the lock
|
|
118
|
+
holder.**
|
|
119
|
+
|
|
120
|
+
**A real defect, fixed; a different defect, responsible.** The two were adjacent in time, and conflating them
|
|
121
|
+
would have produced a changelog entry claiming a fix it cannot demonstrate.
|
|
122
|
+
|
|
123
|
+
### Three of my edits failed on the same mistake before the fourth worked
|
|
124
|
+
|
|
125
|
+
The guard had to be inserted where each file's executable body begins. **My first two markers were text I
|
|
126
|
+
predicted rather than read** — `await main()` does not exist in `w2m-rabbit.mjs`, and `const bound = await bind`
|
|
127
|
+
does not exist in `w2m-stun.mjs`. Then a `Select-Object -Skip` offset was **1-based** while the array index is
|
|
128
|
+
**0-based**, so the script refused both files by one line. **The fourth attempt anchored on the statements' own
|
|
129
|
+
text and succeeded on the first run.** That is the same lesson as the fabricated relay route and the malformed
|
|
130
|
+
PNG, and it is now the fifth time this session.
|
|
131
|
+
|
|
132
|
+
**No test count change** — the guards alter when the bodies run, not what they do, and all four entry points are
|
|
133
|
+
exercised by `scripts/check-entrypoints.mjs`.
|
|
134
|
+
|
|
7
135
|
## [0.58.0] — 2026-10-15
|
|
8
136
|
|
|
9
137
|
**The bridge now reports what it received instead of guessing what an image looks like.**
|
package/TROUBLESHOOTING.md
CHANGED
|
@@ -258,6 +258,59 @@ after removal, remove the `w2m` row from `cordis.patch.yml` by hand (§2) before
|
|
|
258
258
|
**Not us.** If the failure is in a _different_ plugin's entry and this one is merely in the same
|
|
259
259
|
profile, the loader reports the package name in the same line — read it before assuming it is ours.
|
|
260
260
|
|
|
261
|
+
### 3.1 First rule: if a double-click produces NO process, it is not a plugin
|
|
262
|
+
|
|
263
|
+
**This section's decision procedure below will mislead you if you skip this step.** It assumes the
|
|
264
|
+
application *ran and reported something*. A launch that leaves **no process at all** — nothing in Task
|
|
265
|
+
Manager, no window, no dialog — is a different failure, and the plugin is not the cause.
|
|
266
|
+
|
|
267
|
+
**The mechanism**, read out of `@deepseek-ai/dsh-desktop`'s `lib/main.js`:
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
function claimDesktopSingleInstance(application, focusOwner) {
|
|
271
|
+
if (!application.requestSingleInstanceLock()) {
|
|
272
|
+
application.quit(); // no dialog, no error, no window
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
application.on("second-instance", focusOwner);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**A second launch is silently discarded while the first instance holds the lock.** Electron registers that
|
|
281
|
+
lock against the *application*, not against a directory, which is why the two most natural remedies both
|
|
282
|
+
fail:
|
|
283
|
+
|
|
284
|
+
| What you try | Why it does nothing |
|
|
285
|
+
|---|---|
|
|
286
|
+
| **Disable third-party plugins before restarting** | The new process never reaches plugin loading — it quits at the lock |
|
|
287
|
+
| **Reinstall the client (new folder, new exe)** | A new directory does not release a lock registered against the application |
|
|
288
|
+
|
|
289
|
+
**Check the lock before touching anything else:**
|
|
290
|
+
|
|
291
|
+
```powershell
|
|
292
|
+
# Windows
|
|
293
|
+
Get-Process -Name 'DeepSeek Harness' | Select-Object Id, StartTime
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
# macOS / Linux
|
|
298
|
+
pgrep -fl "DeepSeek Harness"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
* **Processes listed** → it is the lock, and the app is not broken. **End them and start again.** On
|
|
302
|
+
Windows: `Stop-Process -Name 'DeepSeek Harness' -Force`. Do **not** delete `~/.dsh`.
|
|
303
|
+
* **Nothing listed** → the failure really is before the lock, and *then* §3's procedure below applies.
|
|
304
|
+
|
|
305
|
+
**A measured case, so the cost is on record.** A restart after upgrading to **v0.58.0** produced no
|
|
306
|
+
process. Disabling third-party plugins and reinstalling the client both changed nothing; **deleting the
|
|
307
|
+
entire `~/.dsh` directory fixed it** — not because anything in that directory was corrupt, but because
|
|
308
|
+
**the deletion took down the process still holding the lock**, and the next launch then became the first
|
|
309
|
+
instance. **Killing that process is the smaller, safer step, and it is the one to try first.**
|
|
310
|
+
|
|
311
|
+
**What this cost, stated plainly:** the deleted directory carried session history and profile
|
|
312
|
+
configuration that no longer exists. **`Stop-Process` would have cost nothing.**
|
|
313
|
+
|
|
261
314
|
---
|
|
262
315
|
|
|
263
316
|
## 4. A machine is listed but never answers
|
package/bin/w2m-localside.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { parseArgs as parseNodeArgs } from 'node:util';
|
|
23
23
|
import process from 'node:process';
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
24
25
|
|
|
25
26
|
import { formatError, resolveStateDir } from '../src/util/cli.mjs';
|
|
26
27
|
import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
|
|
@@ -314,12 +315,34 @@ async function main() {
|
|
|
314
315
|
}
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Run only when executed, never when imported.
|
|
320
|
+
*
|
|
321
|
+
* ## The defect this closes, which was measured rather than reasoned about
|
|
322
|
+
*
|
|
323
|
+
* `package.json` publishes `"./localside": "./bin/w2m-localside.mjs"` in its `exports` map, and this file used to
|
|
324
|
+
* call `main()` unconditionally. **So `import '@twinsearth/w2m-dsh-plugin/localside'` executed a CLI, printed
|
|
325
|
+
* usage, and terminated the process** -- measured, not inferred:
|
|
326
|
+
*
|
|
327
|
+
* import ... /bin/w2m-localside.mjs -> node exited with code 2
|
|
328
|
+
* a line after the import never ran
|
|
329
|
+
*
|
|
330
|
+
* **That is fatal for any process unlucky enough to import the subpath**, and the host is the only plausible
|
|
331
|
+
* candidate: a plugin whose published entry points kill the client is a plugin that makes the client "unable to
|
|
332
|
+
* start", which is exactly what was reported.
|
|
333
|
+
*
|
|
334
|
+
* `w2m-mcp.mjs` already carried this guard, and it was the one file that behaved. **The other three bins had no
|
|
335
|
+
* guard and were published as importable paths** -- so the fix is to give them the same one, not to remove the
|
|
336
|
+
* exports: a subpath that is importable *and* inert is strictly more useful than one that is neither.
|
|
337
|
+
*/
|
|
338
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
339
|
+
main().then(
|
|
340
|
+
(code) => {
|
|
341
|
+
process.exitCode = code;
|
|
342
|
+
},
|
|
343
|
+
(error) => {
|
|
344
|
+
process.stderr.write(`fatal: ${formatError(error)}\n`);
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
}
|
package/bin/w2m-rabbit.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { parseArgs } from 'node:util';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
21
22
|
import { createRelayServer } from '../src/relay/server.mjs';
|
|
22
23
|
import { resolveStateDir, formatError } from '../src/util/cli.mjs';
|
|
23
24
|
|
|
@@ -248,182 +249,196 @@ const stateDir = resolveStateDir(values.state, 'rabbit');
|
|
|
248
249
|
const log = (line) => process.stdout.write(`${line}\n`);
|
|
249
250
|
const diag = (line) => process.stderr.write(`${line}\n`);
|
|
250
251
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
out('');
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
listenInfo = await relay.listen({
|
|
301
|
-
host: values.host ?? '127.0.0.1',
|
|
302
|
-
port,
|
|
303
|
-
});
|
|
304
|
-
pairingCode = listenInfo.pairingCode;
|
|
305
|
-
} catch (error) {
|
|
306
|
-
fail(formatError(error), 3);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const boundPort = listenInfo.port;
|
|
310
|
-
const boundHost = listenInfo.host;
|
|
311
|
-
const scheme = listenInfo.scheme ?? (tlsCert ? 'https' : 'http');
|
|
312
|
-
// Prefer what the relay reports (it knows whether TLS is active); fall back to
|
|
313
|
-
// the bound address so a 0.0.0.0 bind is not advertised as a usable URL.
|
|
314
|
-
const publicUrl = listenInfo.publicUrl ?? `${scheme}://${boundHost === '0.0.0.0' ? '<this-host>' : boundHost}:${boundPort}${basePath === '/' ? '' : basePath}`;
|
|
315
|
-
const resolvedOperatorToken = listenInfo.operatorToken ?? operatorToken ?? null;
|
|
316
|
-
|
|
317
|
-
if (values.json) {
|
|
318
|
-
log(JSON.stringify({
|
|
319
|
-
event: 'listening',
|
|
320
|
-
url: listenInfo.url,
|
|
321
|
-
publicUrl,
|
|
322
|
-
host: boundHost,
|
|
323
|
-
port: boundPort,
|
|
324
|
-
scheme,
|
|
325
|
-
basePath,
|
|
326
|
-
stateDir,
|
|
327
|
-
persist: !values['no-persist'],
|
|
328
|
-
trustProxy: Boolean(values['trust-proxy']),
|
|
329
|
-
pairRateLimit: pairRateLimit ?? 5,
|
|
330
|
-
operatorTokenRequired,
|
|
331
|
-
// The token itself is deliberately absent. This line is the one people pipe
|
|
332
|
-
// into a log or a file, and a credential written there is a credential
|
|
333
|
-
// leaked; it is printed to stderr and stored at
|
|
334
|
-
// <stateDir>/operator-token.txt (0600), which is where a secret belongs.
|
|
335
|
-
operatorTokenPresent: Boolean(operatorTokenRequired && resolvedOperatorToken),
|
|
336
|
-
pairingCode,
|
|
337
|
-
relayId: listenInfo.relayId ?? null,
|
|
338
|
-
protocolVersion: 1,
|
|
339
|
-
}));
|
|
340
|
-
} else {
|
|
341
|
-
log('');
|
|
342
|
-
log(' W2M relay (Rabbit) is listening.');
|
|
343
|
-
log('');
|
|
344
|
-
log(` URL ${publicUrl}`);
|
|
345
|
-
log(` Bind ${boundHost}:${boundPort}${scheme === 'https' ? ' (TLS)' : ''}`);
|
|
346
|
-
if (basePath !== '/') log(` Base path ${basePath}`);
|
|
347
|
-
log(` State ${stateDir}${values['no-persist'] ? ' (persistence DISABLED)' : ''}`);
|
|
348
|
-
log(` Protocol v1 relay id ${listenInfo.relayId ?? '(none)'}`);
|
|
349
|
-
|
|
350
|
-
if (values['trust-proxy']) {
|
|
351
|
-
log('');
|
|
352
|
-
log(' NOTE: --trust-proxy is ON. The relay believes X-Forwarded-For and');
|
|
353
|
-
log(' X-Forwarded-Proto. Only safe behind a proxy you control.');
|
|
354
|
-
}
|
|
355
|
-
if (boundHost === '0.0.0.0' && scheme === 'http') {
|
|
356
|
-
log('');
|
|
357
|
-
log(' NOTE: bound to 0.0.0.0 over plain HTTP. Fine on a Tailscale/WireGuard');
|
|
358
|
-
log(' address (already encrypted); NOT fine on a public interface --');
|
|
359
|
-
log(' use --tls-cert/--tls-key or a TLS-terminating proxy there.');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
log('');
|
|
363
|
-
if (operatorTokenRequired && resolvedOperatorToken) {
|
|
364
|
-
// MEDIUM-6: this banner is the *machine's* stdout, and stdout is what journald, a container log or
|
|
365
|
-
// a CI transcript keeps. The token authorises dispatching to every machine in the group, so it is
|
|
366
|
-
// printed only on the start that generated it — the one moment it exists nowhere else — or when
|
|
367
|
-
// --print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1 asked for it explicitly. Every other start
|
|
368
|
-
// says where to read it and how to change that answer.
|
|
252
|
+
/**
|
|
253
|
+
* Run only when executed, never when imported.
|
|
254
|
+
*
|
|
255
|
+
* `package.json` publishes this file as an `exports` subpath, and an unguarded top-level body means
|
|
256
|
+
* **importing that subpath executes the program**. Measured, not inferred: on the sibling `w2m-localside.mjs`, a
|
|
257
|
+
* statement placed after the import never ran and node exited with code 2.
|
|
258
|
+
*
|
|
259
|
+
* **A published entry point that terminates whichever process imports it is a plugin that stops the client from
|
|
260
|
+
* starting** -- the defect this closes. `w2m-mcp.mjs` already carried the guard and was the only bin that
|
|
261
|
+
* behaved, so the fix gives the others the same one rather than withdrawing the exports: **a subpath that is
|
|
262
|
+
* importable and inert is strictly better than one that is neither.**
|
|
263
|
+
*/
|
|
264
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
265
|
+
let relay;
|
|
266
|
+
let listenInfo;
|
|
267
|
+
let pairingCode;
|
|
268
|
+
try {
|
|
269
|
+
relay = createRelayServer({
|
|
270
|
+
stateDir,
|
|
271
|
+
pairingCode: values['pairing-code'],
|
|
272
|
+
heartbeatIntervalMs: heartbeatMs,
|
|
273
|
+
leaseGraceMs: graceMs,
|
|
274
|
+
// v0.1.2 deployment options
|
|
275
|
+
basePath,
|
|
276
|
+
trustProxy: Boolean(values['trust-proxy']),
|
|
277
|
+
tlsCert: tlsCert ?? null,
|
|
278
|
+
tlsKey: tlsKey ?? null,
|
|
279
|
+
operatorToken,
|
|
280
|
+
operatorTokenRequired,
|
|
281
|
+
// MEDIUM-6: the token is printed by the start that generated it, or when the operator asks for it
|
|
282
|
+
// here (--print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1). The relay resolves both, so the
|
|
283
|
+
// banner below and the relay's own startup lines can never disagree about it.
|
|
284
|
+
printOperatorToken: values['print-operator-token'] === true,
|
|
285
|
+
pairRateLimitPerMinute: pairRateLimit,
|
|
286
|
+
persist: !values['no-persist'],
|
|
287
|
+
// v0.3.0 request signing. Undefined means "not configured", which the relay treats as
|
|
288
|
+
// "do not verify" -- byte-for-byte the v0.2.3 behaviour.
|
|
289
|
+
signingSecret,
|
|
290
|
+
signingSecretPrevious,
|
|
291
|
+
requireSignature,
|
|
292
|
+
metrics: values.metrics === true,
|
|
293
|
+
signatureSkewSeconds,
|
|
294
|
+
});
|
|
295
|
+
// The pairing code rotates on every successful pairing, so the CLI has to
|
|
296
|
+
// follow it rather than print it once: an operator pairing a second machine
|
|
297
|
+
// needs the *current* code, not the one that was already consumed.
|
|
369
298
|
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
299
|
+
// Listeners receive the buffered *entry* (`{seq, event, target}`), not the
|
|
300
|
+
// event itself -- `entry.event` is the frame that goes on the wire.
|
|
301
|
+
relay.state.onEvent((entry) => {
|
|
302
|
+
const event = entry?.event ?? entry;
|
|
303
|
+
if (event?.type === 'notice' && event.code === 'PAIRING_CODE_ROTATED' && event.pairing_code) {
|
|
304
|
+
pairingCode = event.pairing_code;
|
|
305
|
+
// In --json mode this must not touch stdout: a rotation would otherwise
|
|
306
|
+
// corrupt the very output the flag exists to produce.
|
|
307
|
+
const out = values.json ? diag : log;
|
|
308
|
+
out('');
|
|
309
|
+
out(' Pairing code rotated after a successful pairing.');
|
|
310
|
+
out(` 新的配对码 / NEW PAIRING CODE: ${pairingCode}`);
|
|
311
|
+
out('');
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
listenInfo = await relay.listen({
|
|
315
|
+
host: values.host ?? '127.0.0.1',
|
|
316
|
+
port,
|
|
317
|
+
});
|
|
318
|
+
pairingCode = listenInfo.pairingCode;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
fail(formatError(error), 3);
|
|
391
321
|
}
|
|
392
322
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
323
|
+
const boundPort = listenInfo.port;
|
|
324
|
+
const boundHost = listenInfo.host;
|
|
325
|
+
const scheme = listenInfo.scheme ?? (tlsCert ? 'https' : 'http');
|
|
326
|
+
// Prefer what the relay reports (it knows whether TLS is active); fall back to
|
|
327
|
+
// the bound address so a 0.0.0.0 bind is not advertised as a usable URL.
|
|
328
|
+
const publicUrl = listenInfo.publicUrl ?? `${scheme}://${boundHost === '0.0.0.0' ? '<this-host>' : boundHost}:${boundPort}${basePath === '/' ? '' : basePath}`;
|
|
329
|
+
const resolvedOperatorToken = listenInfo.operatorToken ?? operatorToken ?? null;
|
|
330
|
+
|
|
331
|
+
if (values.json) {
|
|
332
|
+
log(JSON.stringify({
|
|
333
|
+
event: 'listening',
|
|
334
|
+
url: listenInfo.url,
|
|
335
|
+
publicUrl,
|
|
336
|
+
host: boundHost,
|
|
337
|
+
port: boundPort,
|
|
338
|
+
scheme,
|
|
339
|
+
basePath,
|
|
340
|
+
stateDir,
|
|
341
|
+
persist: !values['no-persist'],
|
|
342
|
+
trustProxy: Boolean(values['trust-proxy']),
|
|
343
|
+
pairRateLimit: pairRateLimit ?? 5,
|
|
344
|
+
operatorTokenRequired,
|
|
345
|
+
// The token itself is deliberately absent. This line is the one people pipe
|
|
346
|
+
// into a log or a file, and a credential written there is a credential
|
|
347
|
+
// leaked; it is printed to stderr and stored at
|
|
348
|
+
// <stateDir>/operator-token.txt (0600), which is where a secret belongs.
|
|
349
|
+
operatorTokenPresent: Boolean(operatorTokenRequired && resolvedOperatorToken),
|
|
350
|
+
pairingCode,
|
|
351
|
+
relayId: listenInfo.relayId ?? null,
|
|
352
|
+
protocolVersion: 1,
|
|
353
|
+
}));
|
|
354
|
+
} else {
|
|
398
355
|
log('');
|
|
399
|
-
log('
|
|
356
|
+
log(' W2M relay (Rabbit) is listening.');
|
|
400
357
|
log('');
|
|
401
|
-
log(`
|
|
402
|
-
log('
|
|
358
|
+
log(` URL ${publicUrl}`);
|
|
359
|
+
log(` Bind ${boundHost}:${boundPort}${scheme === 'https' ? ' (TLS)' : ''}`);
|
|
360
|
+
if (basePath !== '/') log(` Base path ${basePath}`);
|
|
361
|
+
log(` State ${stateDir}${values['no-persist'] ? ' (persistence DISABLED)' : ''}`);
|
|
362
|
+
log(` Protocol v1 relay id ${listenInfo.relayId ?? '(none)'}`);
|
|
363
|
+
|
|
364
|
+
if (values['trust-proxy']) {
|
|
365
|
+
log('');
|
|
366
|
+
log(' NOTE: --trust-proxy is ON. The relay believes X-Forwarded-For and');
|
|
367
|
+
log(' X-Forwarded-Proto. Only safe behind a proxy you control.');
|
|
368
|
+
}
|
|
369
|
+
if (boundHost === '0.0.0.0' && scheme === 'http') {
|
|
370
|
+
log('');
|
|
371
|
+
log(' NOTE: bound to 0.0.0.0 over plain HTTP. Fine on a Tailscale/WireGuard');
|
|
372
|
+
log(' address (already encrypted); NOT fine on a public interface --');
|
|
373
|
+
log(' use --tls-cert/--tls-key or a TLS-terminating proxy there.');
|
|
374
|
+
}
|
|
375
|
+
|
|
403
376
|
log('');
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
377
|
+
if (operatorTokenRequired && resolvedOperatorToken) {
|
|
378
|
+
// MEDIUM-6: this banner is the *machine's* stdout, and stdout is what journald, a container log or
|
|
379
|
+
// a CI transcript keeps. The token authorises dispatching to every machine in the group, so it is
|
|
380
|
+
// printed only on the start that generated it — the one moment it exists nowhere else — or when
|
|
381
|
+
// --print-operator-token / W2M_PRINT_OPERATOR_TOKEN=1 asked for it explicitly. Every other start
|
|
382
|
+
// says where to read it and how to change that answer.
|
|
383
|
+
//
|
|
384
|
+
// DELIBERATE, do not "fix": on that generating start the relay's own startup line prints the value
|
|
385
|
+
// too (on stderr). The pair is the intended shape — this box is the operator-facing handoff, that
|
|
386
|
+
// line is the log copy — and every other start prints it in neither place.
|
|
387
|
+
if (listenInfo.operatorTokenPrintable === true) {
|
|
388
|
+
log(' ┌────────────────────────────────────────────────────────────┐');
|
|
389
|
+
log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
|
|
390
|
+
log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
|
|
391
|
+
log(' └────────────────────────────────────────────────────────────┘');
|
|
392
|
+
log(` Printed once because ${listenInfo.operatorTokenPrintReason === 'generated'
|
|
393
|
+
? 'this start generated it'
|
|
394
|
+
: `${listenInfo.operatorTokenPrintReason ?? 'an option'} asked for it`}.`);
|
|
395
|
+
log(` Also written to ${stateDir}/operator-token.txt (0600).`);
|
|
396
|
+
} else {
|
|
397
|
+
log(' OPERATOR TOKEN — not printed (this start did not generate it).');
|
|
398
|
+
log(` Read it from ${stateDir}/operator-token.txt (0600).`);
|
|
399
|
+
log(' To print it here as well: --print-operator-token, or W2M_PRINT_OPERATOR_TOKEN=1.');
|
|
400
|
+
}
|
|
401
|
+
log(' Device tokens cannot send work; this is what authorises it.');
|
|
402
|
+
} else if (!operatorTokenRequired) {
|
|
403
|
+
log(' ⚠ OPERATOR TOKEN REQUIREMENT IS OFF (--operator-token "").');
|
|
404
|
+
log(' Any paired device can send work to any other. Trusted LAN only.');
|
|
405
|
+
}
|
|
406
|
+
|
|
410
407
|
log('');
|
|
408
|
+
if (pairingCode) {
|
|
409
|
+
log(' ┌──────────────────────────────────────────────┐');
|
|
410
|
+
log(` │ 配对码 / PAIRING CODE: ${String(pairingCode).padEnd(19)}│`);
|
|
411
|
+
log(' └──────────────────────────────────────────────┘');
|
|
412
|
+
log('');
|
|
413
|
+
log(' Run this on every machine you want in the group:');
|
|
414
|
+
log('');
|
|
415
|
+
log(` w2m-localside --rabbit ${listenInfo.url} --pair ${pairingCode} \\`);
|
|
416
|
+
log(' --project <path-to-project> --name <machine-name>');
|
|
417
|
+
log('');
|
|
418
|
+
log(' The code rotates after each successful pairing; the new one is');
|
|
419
|
+
log(' printed here, so you can pair the next machine without restarting.');
|
|
420
|
+
}
|
|
421
|
+
if (boundHost === '0.0.0.0') {
|
|
422
|
+
log(' NOTE: bound to 0.0.0.0 -- other machines on your network can reach this');
|
|
423
|
+
log(' relay. It carries no TLS and no authentication beyond device tokens.');
|
|
424
|
+
log('');
|
|
425
|
+
}
|
|
411
426
|
}
|
|
412
|
-
}
|
|
413
427
|
|
|
414
|
-
let stopping = false;
|
|
415
|
-
async function shutdown(signal) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
428
|
+
let stopping = false;
|
|
429
|
+
async function shutdown(signal) {
|
|
430
|
+
if (stopping) return;
|
|
431
|
+
stopping = true;
|
|
432
|
+
if (!values.json) log(`\n ${signal} received, shutting down...`);
|
|
433
|
+
try {
|
|
434
|
+
await relay.close();
|
|
435
|
+
} catch (error) {
|
|
436
|
+
process.stderr.write(`w2m-rabbit: error while closing: ${formatError(error)}\n`);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
process.exit(0);
|
|
424
440
|
}
|
|
425
|
-
process.exit(0);
|
|
426
|
-
}
|
|
427
441
|
|
|
428
|
-
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
429
|
-
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
442
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
443
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
444
|
+
}
|
package/bin/w2m-stun.mjs
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { parseArgs } from 'node:util';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
30
31
|
import { createStunServer, DEFAULT_SOFTWARE } from '../src/relay/stun-server.mjs';
|
|
31
32
|
import { formatError } from '../src/util/cli.mjs';
|
|
32
33
|
|
|
@@ -154,78 +155,92 @@ function logRequest(entry) {
|
|
|
154
155
|
);
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Run only when executed, never when imported.
|
|
160
|
+
*
|
|
161
|
+
* `package.json` publishes this file as an `exports` subpath, and an unguarded top-level body means
|
|
162
|
+
* **importing that subpath executes the program**. Measured, not inferred: on the sibling `w2m-localside.mjs`, a
|
|
163
|
+
* statement placed after the import never ran and node exited with code 2.
|
|
164
|
+
*
|
|
165
|
+
* **A published entry point that terminates whichever process imports it is a plugin that stops the client from
|
|
166
|
+
* starting** -- the defect this closes. `w2m-mcp.mjs` already carried the guard and was the only bin that
|
|
167
|
+
* behaved, so the fix gives the others the same one rather than withdrawing the exports: **a subpath that is
|
|
168
|
+
* importable and inert is strictly better than one that is neither.**
|
|
169
|
+
*/
|
|
170
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
171
|
+
let server;
|
|
172
|
+
let listen;
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
try {
|
|
175
|
+
server = createStunServer({ host, port, software, log: logRequest });
|
|
176
|
+
listen = await server.start();
|
|
177
|
+
} catch (error) {
|
|
178
|
+
fail(formatError(error), 3);
|
|
179
|
+
}
|
|
166
180
|
|
|
167
|
-
if (!listen.ok) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
if (!listen.ok) {
|
|
182
|
+
// EADDRINUSE deserves its own sentence: it is the one failure an operator will
|
|
183
|
+
// actually hit, and the fix (find the other listener, or pick another port) is
|
|
184
|
+
// not obvious from "EADDRINUSE".
|
|
185
|
+
if (listen.error === 'EADDRINUSE') {
|
|
186
|
+
fail(
|
|
187
|
+
`UDP port ${port} is already in use on ${host} — another STUN server or another ` +
|
|
188
|
+
`w2m-stun is holding it. Stop it, or choose a different --port.`,
|
|
189
|
+
3,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
fail(`failed to bind ${host}:${port} — ${listen.error}`, 3);
|
|
177
193
|
}
|
|
178
|
-
fail(`failed to bind ${host}:${port} — ${listen.error}`, 3);
|
|
179
|
-
}
|
|
180
194
|
|
|
181
|
-
const bound = server.address() ?? { address: listen.host, port: listen.port };
|
|
182
|
-
log('');
|
|
183
|
-
log(' W2M STUN responder is listening.');
|
|
184
|
-
log('');
|
|
185
|
-
log(` Bind udp4 ${bound.address}:${bound.port}`);
|
|
186
|
-
log(` Software ${software}`);
|
|
187
|
-
log(' Answers Binding requests (0x0001) -> 0x0101 with XOR-MAPPED-ADDRESS,');
|
|
188
|
-
log(' MAPPED-ADDRESS and SOFTWARE. Anything else is dropped.');
|
|
189
|
-
log('');
|
|
190
|
-
log(' NAT translation:');
|
|
191
|
-
log(' A machine behind NAT cannot see the public address and port its packets');
|
|
192
|
-
log(
|
|
193
|
-
` leave from. Point clients at ${bound.address === '0.0.0.0' ? '<this-host>' : bound.address}:${bound.port} (UDP) and`,
|
|
194
|
-
);
|
|
195
|
-
log(' this server reports the translated address back, which is what lets two');
|
|
196
|
-
log(' machines punch a direct path instead of relaying every task.');
|
|
197
|
-
if (bound.address === '0.0.0.0') {
|
|
195
|
+
const bound = server.address() ?? { address: listen.host, port: listen.port };
|
|
198
196
|
log('');
|
|
199
|
-
log('
|
|
200
|
-
log('
|
|
201
|
-
log(
|
|
202
|
-
}
|
|
203
|
-
log('');
|
|
204
|
-
log(
|
|
205
|
-
log('');
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
async function shutdown(signal) {
|
|
209
|
-
if (stopping) return;
|
|
210
|
-
stopping = true;
|
|
211
|
-
const stats = server.stats();
|
|
197
|
+
log(' W2M STUN responder is listening.');
|
|
198
|
+
log('');
|
|
199
|
+
log(` Bind udp4 ${bound.address}:${bound.port}`);
|
|
200
|
+
log(` Software ${software}`);
|
|
201
|
+
log(' Answers Binding requests (0x0001) -> 0x0101 with XOR-MAPPED-ADDRESS,');
|
|
202
|
+
log(' MAPPED-ADDRESS and SOFTWARE. Anything else is dropped.');
|
|
203
|
+
log('');
|
|
204
|
+
log(' NAT translation:');
|
|
205
|
+
log(' A machine behind NAT cannot see the public address and port its packets');
|
|
212
206
|
log(
|
|
213
|
-
|
|
214
|
-
`requests=${stats.requests} responses=${stats.responses} errors=${stats.errors} ` +
|
|
215
|
-
`uptime=${Math.round((Date.now() - startedAt) / 100) / 10}s`,
|
|
207
|
+
` leave from. Point clients at ${bound.address === '0.0.0.0' ? '<this-host>' : bound.address}:${bound.port} (UDP) and`,
|
|
216
208
|
);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
209
|
+
log(' this server reports the translated address back, which is what lets two');
|
|
210
|
+
log(' machines punch a direct path instead of relaying every task.');
|
|
211
|
+
if (bound.address === '0.0.0.0') {
|
|
212
|
+
log('');
|
|
213
|
+
log(' NOTE: bound to 0.0.0.0 — reachable from anywhere that can route UDP to this');
|
|
214
|
+
log(' host. There is no authentication and no state: every request is');
|
|
215
|
+
log(' answered on the address it came from, and nothing else is possible.');
|
|
222
216
|
}
|
|
223
|
-
|
|
224
|
-
}
|
|
217
|
+
log('');
|
|
218
|
+
log(` Log level ${logLevel}${debug ? '' : ' (--log-level debug prints one line per request)'}`);
|
|
219
|
+
log('');
|
|
225
220
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
221
|
+
let stopping = false;
|
|
222
|
+
async function shutdown(signal) {
|
|
223
|
+
if (stopping) return;
|
|
224
|
+
stopping = true;
|
|
225
|
+
const stats = server.stats();
|
|
226
|
+
log(
|
|
227
|
+
`\n ${signal} received, shutting down. ` +
|
|
228
|
+
`requests=${stats.requests} responses=${stats.responses} errors=${stats.errors} ` +
|
|
229
|
+
`uptime=${Math.round((Date.now() - startedAt) / 100) / 10}s`,
|
|
230
|
+
);
|
|
231
|
+
try {
|
|
232
|
+
await server.stop();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
process.stderr.write(`w2m-stun: error while closing: ${formatError(error)}\n`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
process.on('SIGINT', () => {
|
|
241
|
+
void shutdown('SIGINT');
|
|
242
|
+
});
|
|
243
|
+
process.on('SIGTERM', () => {
|
|
244
|
+
void shutdown('SIGTERM');
|
|
245
|
+
});
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.60.0",
|
|
5
5
|
"description": "Cross-platform fleet execution between Windows and macOS: one DeepSeek Harness account, a Rabbit relay, and signed device identity. Ships four Agent Skills describing fleet dispatch, field-level memory acceptance, session references, and resource scheduling.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -135,7 +135,7 @@ export function textOf(message) {
|
|
|
135
135
|
* @param {{maxKeys?: number, maxDepth?: number}} [options]
|
|
136
136
|
* @returns {string} a compact, content-free description
|
|
137
137
|
*/
|
|
138
|
-
export function describeItems(message, { maxKeys = 24, maxDepth =
|
|
138
|
+
export function describeItems(message, { maxKeys = 24, maxDepth = 5 } = {}) {
|
|
139
139
|
const items = Array.isArray(message?.item_list) ? message.item_list : [];
|
|
140
140
|
if (items.length === 0) return 'item_list is empty or absent';
|
|
141
141
|
|
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.60.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;
|