@twinsearth/w2m-dsh-plugin 0.5.5 → 0.5.6

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 CHANGED
@@ -54,6 +54,77 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
54
54
  * Still unverified, and not claimed: how the control *renders*. No browser was opened. What is proven is
55
55
  the entire host side — the entry activates, the eight tools register, the boot-graph row is composed
56
56
  from the shipped bundle, the route is registered and attached — plus the seat shape in a VM test.
57
+ ## [0.5.6] — 2026-10-11
58
+
59
+ ### Fixed
60
+
61
+ * **The client entry could not activate, and that made the whole application refuse to start.**
62
+ v0.5.4 and v0.5.5 were withdrawn for it. The real cause, taken from the page itself rather than
63
+ guessed:
64
+
65
+ ```
66
+ w2m-client-probe: apply threw for @twinsearth/w2m-dsh-plugin: cannot get property "locale" without inject
67
+ Error: web boot: 1 entry did not activate
68
+ ```
69
+
70
+ Cordis throws for any `ctx.<name>` a fiber reads without declaring it, and the client half declared
71
+ `inject = []`. **The first throw was `locale`, not `slots`** — so declaring the seat alone, or guarding
72
+ the locale alone, would each still have died. Now `inject = ['slots']` (a declared name is a *required*
73
+ dependency, which is why `locale` is deliberately not among them), the optional carrier is read
74
+ inject-free with `ctx.get('locale')` while its dictionary registration rides a child fiber, a context
75
+ with no usable seat becomes a **no-op with a diagnostic** instead of taking the page down, and `apply`
76
+ prints the inner message to the error-level renderer console — the channel the crash log reads — and
77
+ then rethrows, because a swallow would boot a healthy-looking page with no control.
78
+ * **A failed entry was invisible unless you read fiber state.** `cordis-plugin-loader` no longer
79
+ propagates a failed mount to `await ctx.loader.await()`, and the loader driver inspected only the one
80
+ entry it knew about — so a failure landing on a **child** fiber (our silently-refused route) went
81
+ unseen. The driver now sweeps the whole registry and reports `failedFibers`.
82
+
83
+ ### Added
84
+
85
+ * **A gate that would have caught all of it**: `test/loader-entry.test.mjs` packs the tree, installs the
86
+ tarball with the installation's own `dsh plugin add` into a throwaway profile under a throwaway
87
+ `DSH_HOME`, boots it with the real `runProfile`, activates the client entries through the real module
88
+ system, and — when a browser is present — boots the profile's own page and asserts our control renders.
89
+ Proven in both directions: **10/10 on this tree, 2 failures against the preserved pre-fix tarball, 9
90
+ against the v0.5.4 one**, and with a deliberately injected route defect it names the **child** fiber
91
+ while "entry active" and "eight tools" still pass. It reports one stage per line, so a failure names
92
+ its cause.
93
+ * **`test/claims.test.mjs`** — 22 checks that our own published claims match the artifact, with the
94
+ registered tool set *measured* by importing the entry. It caught a stale claim in our README on its
95
+ **first run** (`README.md` said the installer asserts 5 tools; it asserts 8), and its author's own
96
+ mutation proof caught two false greens in its first draft — a scan that extracted zero tool names and
97
+ a lookahead that invented one out of a metric.
98
+ * **`TROUBLESHOOTING.md`** — 17 symptom-indexed sections that name the culprit when it is not us (pnpm's
99
+ `minimumReleaseAge`, carrier-grade NAT, the OS firewall, an installer that rewrites the profile patch),
100
+ ending with a data-location table and a reason-code table.
101
+ * **`SECURITY.md`** — a private reporting channel and **no public-issue route**, an honest support table,
102
+ and the consent model written out: what a paired machine may and may not do, and that we never write to
103
+ another machine's `authorized_keys`.
104
+ * **`CONTRIBUTING.md`** — one page of falsifiable conventions, including **"a test that would pass
105
+ against a broken artifact is not evidence"** and **"run mutation proofs in a copy, never in the shared
106
+ tree"** (both learned the hard way).
107
+ * **Opt-in LAN discovery** (`src/agent/mdns-wire.mjs`, `src/agent/mdns.mjs`, `src/plugin/mdns-discover.mjs`):
108
+ a bounded, hand-written DNS/mDNS codec with parse hardening (a pointer may only name a *prior* offset, so
109
+ termination is a property of the format; a 128-hop budget reported separately from a loop; label, name,
110
+ record and packet caps; every read bounds-checked) and a discovery source exposed through
111
+ `w2m_devices`' new `include_discovered` (default `false`). **Discovery is a hint, never an
112
+ authorisation**: a discovered device is refused as a dispatch target unless the relay already lists it
113
+ and it is online, and the positive control is tested so the rule cannot pass by refusing everything.
114
+
115
+ ### Notes
116
+
117
+ * **v0.5.4 and v0.5.5 remain DO NOT INSTALL.** This release also exists so that `latest` on the registry,
118
+ which still pointed at v0.5.5, stops handing out a version that prevents the application from starting.
119
+ * **Upgrading from v0.5.4 needs one edit outside this package**: the profile's `cordis.patch.yml` must
120
+ name the package (`@twinsearth/w2m-dsh-plugin`), not the `/tools` subpath. With the corrected bundle row
121
+ a mismatch makes the loader skip the whole patch and silently revert every configured value to its
122
+ default. The claims gate now checks this.
123
+ * **LAN discovery is unverified on a real network.** Every socket in its suite is loopback and no packet
124
+ left the host, so it says nothing about whether a switch forwards `224.0.0.251` or how a real
125
+ Bonjour/Avahi peer answers. There is **no third-party interoperability test**, no probing or conflict
126
+ resolution, IPv4 only, and the responder is **built and tested but not started** by the plugin —
127
+ loading a plugin should not silently multicast on the host's network.
57
128
  ## [0.5.4] — 2026-10-11
58
129
 
59
130
  > **DO NOT INSTALL 0.5.4.** It fails to load in the Harness: the loader entry
package/README.md CHANGED
@@ -535,7 +535,7 @@ the plugin:
535
535
 
536
536
  ```bash
537
537
  node scripts/check-entrypoints.mjs # imports lib/tools.js, the exact specifier cordis.patch.yml mounts
538
- node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 5 tools
538
+ node scripts/verify-installed.mjs <path-to-installed-package> # asserts the installed copy registers 8 tools
539
539
  ```
540
540
 
541
541
  **Not verified:**
@@ -588,6 +588,8 @@ Issues and pull requests are welcome. If you change observable behaviour, update
588
588
  `PROTOCOL.md` in the same commit — that file is the contract three components
589
589
  agree on.
590
590
 
591
+ When something does not work, start from the symptom: [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
592
+
591
593
  ## License
592
594
 
593
595
  [MIT](LICENSE)
package/lib/client.js CHANGED
@@ -8,7 +8,25 @@
8
8
  * web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
9
9
  * whose factory runs at materialization). `package.json` declares it with
10
10
  * `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
11
- * host scans for; `src/client/w2m-target.mjs` is the same code laid out for a human reader.
11
+ * host scans for. There is no second copy to keep in sync: this file **is** the source, and
12
+ * `test/client-bundle.test.mjs` is where its entry shape is read.
13
+ *
14
+ * # A failed client entry is fatal, and silence is not a neutral failure
15
+ *
16
+ * Measured twice in production: when this entry's `apply` throws, the page does not degrade — the
17
+ * shell audits every entry after activation and refuses to boot with
18
+ * `web boot: 1 entry did not activate / @twinsearth/w2m-dsh-plugin: failed`, which in the desktop
19
+ * app is forwarded to the main process and written as a `web-boot` crash: **the app does not start**.
20
+ * The audit prints the entry's *name* and its fiber state and nothing else, so the cause has to be
21
+ * carried by the failing code itself; {@link apply} therefore reports its own error to
22
+ * `console.error`, which is the error-level renderer console the crash log already captures.
23
+ *
24
+ * The failure that cost those two outages was a single property read. Cordis resolves `ctx.<name>`
25
+ * through the fiber's `inject` list: a service the fiber never declared **throws**
26
+ * (`cannot get property "locale" without inject`) whether or not some plugin provides it, and every
27
+ * name that *is* declared becomes a required dependency the entry waits for. Both halves of that rule
28
+ * are load-bearing here — see {@link inject} for what this bundle declares, and how the optional
29
+ * locale carrier is read instead.
12
30
  *
13
31
  * Two consequences, both deliberate:
14
32
  *
@@ -109,12 +127,18 @@ window.__ModuleLoader__.load({
109
127
  * `{name}` substitution is deliberately the only templating: these strings have one variable
110
128
  * each, and a message format would be a dependency this package does not have.
111
129
  *
112
- * @param ctx - the client plugin context.
130
+ * @param localeOf - the shell's locale service, or a function returning it (read at lookup time).
131
+ * Passed in rather than read from `ctx` here: the service is optional, and reading an optional
132
+ * service off the context is the one operation in this file that can kill the page (see
133
+ * {@link readOptionalService}). A function is accepted because the service may arrive *after*
134
+ * this entry activates — the shell's `bind()` is live, so a deferred read still localises.
113
135
  * @returns {(key: string, values?: object) => string}
114
136
  */
115
- function makeTranslator(ctx) {
116
- const bound = typeof ctx?.locale?.bind === 'function' ? ctx.locale.bind(NS) : null;
137
+ function makeTranslator(localeOf) {
138
+ const resolveLocale = () => (typeof localeOf === 'function' ? localeOf() : localeOf);
117
139
  return (key, values) => {
140
+ const locale = resolveLocale();
141
+ const bound = typeof locale?.bind === 'function' ? locale.bind(NS) : null;
118
142
  const template = bound === null
119
143
  ? (DICT.zh[key] ?? DICT.en[key] ?? key)
120
144
  : bound(key, values);
@@ -124,6 +148,50 @@ window.__ModuleLoader__.load({
124
148
  };
125
149
  }
126
150
 
151
+ /**
152
+ * Read a service the composition may or may not provide, without the inject requirement.
153
+ *
154
+ * `ctx.get(name)` is Cordis's inject-free lookup, and it is the only correct way to read an
155
+ * *optional* service from a plugin: `ctx[name]` throws for anything the fiber did not declare as
156
+ * a dependency, and declaring it would make the service *required* — an entry whose declared
157
+ * dependency never arrives never activates either, which is fatal in exactly the same way. So a
158
+ * carrier that must not be able to block the control (`locale`) is read here, and a service the
159
+ * control cannot work without (`slots`) is declared in {@link inject}.
160
+ *
161
+ * @param ctx - the client plugin context.
162
+ * @param name - the service name.
163
+ * @returns the service, or undefined when the composition has none (or this is not a Cordis
164
+ * context at all, which is how `test/client-bundle.test.mjs` applies this bundle).
165
+ */
166
+ function readOptionalService(ctx, name) {
167
+ return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
168
+ }
169
+
170
+ /**
171
+ * Register {@link DICT} with the shell's locale service whenever that service exists.
172
+ *
173
+ * Two paths, one outcome. When the service is already up, the registration is an effect of this
174
+ * entry and goes away with it. When it is not up yet, the registration rides a **child fiber**
175
+ * (`ctx.inject`) that waits for `locale` without holding this entry back: declaring `locale` in
176
+ * {@link inject} instead would make it required, and a composition without a locale service
177
+ * would then report this entry as `pending` — which the shell's audit treats exactly like
178
+ * `failed`, i.e. the page does not boot. A missing localisation service must never cost an
179
+ * operator the control, and must never cost them the app.
180
+ *
181
+ * @param ctx - the client plugin context.
182
+ * @param locale - the shell's locale service, or undefined when the composition has none.
183
+ */
184
+ function registerDictionaries(ctx, locale) {
185
+ if (typeof locale?.register === 'function') {
186
+ ctx.effect(() => locale.register(NS, DICT), 'w2m: composer target control dictionaries');
187
+ return;
188
+ }
189
+ if (typeof ctx?.inject !== 'function') return;
190
+ ctx.inject(['locale'], (localeCtx) => {
191
+ localeCtx.effect(() => localeCtx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
192
+ });
193
+ }
194
+
127
195
  /** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
128
196
  async function callHost(options) {
129
197
  let response;
@@ -463,7 +531,33 @@ window.__ModuleLoader__.load({
463
531
  }
464
532
 
465
533
  /**
466
- * Client plugin body.
534
+ * Client plugin body: install the stylesheet, register the dictionaries, take the seat.
535
+ *
536
+ * @param ctx - the client root context.
537
+ */
538
+ function apply(ctx) {
539
+ try {
540
+ mount(ctx);
541
+ } catch (error) {
542
+ // The audit that decides whether the app boots reports the entry's name and the word
543
+ // "failed" and nothing else, so this is where the cause has to be said out loud. The
544
+ // renderer console at error level is the channel the desktop crash log already carries
545
+ // (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
546
+ // browser half can put its own diagnosis next to the failure it caused.
547
+ console.error(
548
+ '@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
549
+ `${error instanceof Error ? error.message : String(error)}`,
550
+ error,
551
+ );
552
+ // Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
553
+ // target control in it — the silent-half-arrival failure this package has already shipped
554
+ // once (v0.5.4) and refuses to ship again.
555
+ throw error;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
467
561
  *
468
562
  * `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
469
563
  * from a package that does not own the seat: the parent declared `conversation.input.left`, and
@@ -477,17 +571,30 @@ window.__ModuleLoader__.load({
477
571
  *
478
572
  * @param ctx - the client root context.
479
573
  */
480
- function apply(ctx) {
574
+ function mount(ctx) {
575
+ // `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
576
+ // what makes this entry wait for the seat instead of racing it. The shape check is for callers
577
+ // that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
578
+ // registry is a control that cannot be mounted, and a control that cannot be mounted must not
579
+ // be allowed to take the whole page down with it.
580
+ const slots = ctx?.slots;
581
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
582
+ console.error(
583
+ '@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
584
+ 'dispatch-target control is not mounted; the rest of the page is unaffected',
585
+ );
586
+ return;
587
+ }
481
588
  const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
482
589
  if (styleDisposer !== null && typeof ctx.effect === 'function') {
483
590
  ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
484
591
  }
485
- // Dictionaries first: a namespace registered after the UI has mounted is still picked up.
486
- if (typeof ctx.locale?.register === 'function') {
487
- ctx.effect(() => ctx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
488
- }
489
- const t = makeTranslator(ctx);
490
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
592
+ // Dictionaries as early as the composition allows: a namespace registered after the UI has
593
+ // mounted is still picked up, and the service is optional, so nothing here may block.
594
+ const locale = readOptionalService(ctx, 'locale');
595
+ registerDictionaries(ctx, locale);
596
+ const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
597
+ slots.inject('conversation.input.left', () => slots.register({
491
598
  name: 'conversation.input.left',
492
599
  id: 'w2m-target',
493
600
  // After the shipped permission control and the plan control, which sit earlier in the
@@ -502,8 +609,26 @@ window.__ModuleLoader__.load({
502
609
  }, TargetSelect));
503
610
  }
504
611
 
505
- /** Cordis services this entry needs; `ctx.slots` is read directly, as every client plugin does. */
506
- const inject = [];
612
+ /**
613
+ * Cordis services this entry depends on, read by the fiber that mounts this bundle.
614
+ *
615
+ * `slots` is the seat: the control is registered into `conversation.input.left` through the
616
+ * slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
617
+ * not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
618
+ * service **throws** (`cannot get property "slots" without inject`) even when the service is
619
+ * provided — measured in production: this bundle shipped with `inject = []` while reading
620
+ * `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
621
+ *
622
+ * `locale` is deliberately absent, and that is the other half of the same rule: **every**
623
+ * declared name is a required dependency, so an entry that declares a service the composition
624
+ * does not provide never reaches `active` either — the audit then reports it as
625
+ * `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
626
+ * with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
627
+ * useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
628
+ * {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
629
+ * on any undeclared service read, exactly as Cordis does.
630
+ */
631
+ const inject = ['slots'];
507
632
 
508
633
  exports.apply = apply;
509
634
  exports.inject = inject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
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",