@twinsearth/w2m-dsh-plugin 0.59.0 → 1.0.1

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
@@ -1,9 +1,246 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project are documented here. The format follows
3
+ All notable changes to this project are documented here.
4
+
5
+ ## [1.0.1] — 2026-10-16
6
+
7
+ **The first stable release. Three defects fixed, two of them mine and one of them published 56 times.**
8
+
9
+ ### The command allow-list could not admit the interpreter DSH itself runs on
10
+
11
+ Twelve end-to-end tests failed with `COMMAND_EXECUTABLE_PATH_MISMATCH`, and **the refusal was correct** —
12
+ the identity check was reading the wrong machine.
13
+
14
+ bundled node : E:\DS\resources\runtime\primary-runtime\dependencies\node\bin\node.exe
15
+ PATH : E:\Unity3D\Tuanjie Cowork\cli\bin\win32-x64\node.exe <- a different program
16
+ PATH contains the bundled directory : False
17
+
18
+ The tests dispatch `[process.execPath, '-e', …]` against an allow-list entry of the bare name `node`. **DSH runs
19
+ on the bundled interpreter and does not put its directory on PATH**, so the bare name resolved to an unrelated
20
+ `node.exe` shipped by another application, and an absolute path to the bundled copy was refused as "a different
21
+ file".
22
+
23
+ **Fixed by answering the right question.** `resolveCommand` still keeps PATH ahead of the bundled tree — a test
24
+ guards that precedence, and it is about *which file a name means*. The new `bundledExecutable()` answers a
25
+ different question: *is this path the bundled copy of that tool?* **The bundled copy of a tool is that tool**, so
26
+ identity holds, and **nothing is weakened**: it only ever returns files under `<deps>`, so
27
+ `E:\Temp\evil\node.exe` is still refused.
28
+
29
+ **The runtime is now discovered rather than assumed.** `dshDependenciesDir()` tried exactly one path,
30
+ `$DSH_HOME/dsh-runtimes/<runtime>/dependencies`, which stopped existing when the application moved its runtime
31
+ inside the bundle. It now tries the explicit override, then that documented layout, then **the directory implied
32
+ by `process.execPath`** — a bundled interpreter lives at `<deps>/node/bin/node`, so its own path states where
33
+ `<deps>` is. **That cannot go stale when the installation moves again.**
34
+
35
+ ### 56 of 66 release scripts published v0.1.3's commit message under an unrelated title
36
+
37
+ Found because a test plan written against v0.60.0 reported its release notes as "highly duplicated" with several
38
+ earlier releases. **The scripts state the right intention and then read the wrong file:**
39
+
40
+ /** The release notes reuse the commit message */
41
+ const NOTES = fs.readFileSync('…\\commit-0103b.txt', 'utf8'); // v0.1.3's message
42
+ name: 'v0.60.0 — approval index, fleet sync, service health, tiered storage', // hardcoded
43
+
44
+ `scripts/github-release.mjs` **derives both fields from the repository's own record** — the title from the commit
45
+ subject, the body from the CHANGELOG entry — **so neither can be copied by accident.** It refuses to publish a
46
+ version with no CHANGELOG entry, and reads the release back after writing. **The five most-read releases
47
+ (v0.60.0, v0.59.0, v0.58.0, v0.57.1, v0.57.0) are corrected**, each verified by reading it back.
48
+
49
+ ### My mount-row advice broke the client twice, and §3.2 withdraws it
50
+
51
+ **An installed plugin mounts itself**, from its own bundle patch — so adding a row to `cordis.patch.yml` inserts
52
+ a **second** entry with the same `id`, and the client stops opening. Measured: **0 rows before install, exactly 1
53
+ after, 2 with the manual row, 1 again once removed.**
54
+
55
+ ### Also in this release
56
+
57
+ **Two client failures had one cause, and it was this repository's own advice: do not add a mount row.**
58
+
59
+ ### The plugin mounts itself
60
+
61
+ `package.json` declares `dsh.bundle.patch`, so **the host composes an installed package from the patch shipped
62
+ inside the package**. Nothing needs to be written into the profile — and writing it anyway inserts a **second**
63
+ entry with the same `id`, which stops the client from opening.
64
+
65
+ **Measured on a throwaway profile**, because the desktop profile cannot be previewed (`rejectElectronProfile`)
66
+ and that is exactly where the failures happened:
67
+
68
+ | State | `id: w2m` rows |
69
+ |---|---|
70
+ | profile created, plugin not installed | 0 |
71
+ | **plugin installed, nothing else done** | **1** |
72
+ | a correctly written `insert:` row added as well | **2** ← the failure |
73
+ | that row removed | **1** |
74
+
75
+ **The symptom is worth stating precisely, because it is not a crash:** the client does not open, there is no
76
+ error message, and deleting `cordis.patch.yml` restores it immediately. **That is what pointed at configuration
77
+ rather than code.**
78
+
79
+ ### §3.2, and why the old advice is withdrawn
80
+
81
+ The section now leads with the rule — **install, and change nothing else** — then covers the three ways an edit
82
+ goes wrong:
83
+
84
+ * **`insert:` is an append.** Appending a row whose `id` already exists is the failure. To change configuration,
85
+ address the row the bundle patch created, as a non-insert patch.
86
+ * **`Add-Content` onto this file is always wrong.** It is a YAML list; a fresh profile ships `[]`, and a second
87
+ document after it is a parse error. **The measured exception message is in the section**, because "failed to
88
+ parse overlay" alone sends people looking at the wrong thing.
89
+ * **The desktop profile has no safe preview.** `--dump-config` composes and exits without starting anything, and
90
+ it is the right tool — but the CLI refuses the desktop profile, so for that one the only protection is writing
91
+ the row correctly. **Which in practice means not writing one.**
92
+
93
+ ### What I got wrong, since the section exists because of it
94
+
95
+ I added the row to a live profile without a way to test it that did not involve the user's client, and the
96
+ backup went with the profile when it was reset. **The cost was two client failures and a profile reset.**
97
+
98
+ **I also formed a plausible hypothesis and disproved it before writing any of this**: I suspected
99
+ `Add-Content -Encoding utf8` had written a mid-file BOM. Measured on PowerShell 5.1 — a 45-byte file with the
100
+ BOM only at offset 0. **Not the cause, and recorded so it is not repeated.**
101
+
102
+ **The experiment that settled it took one command and started nothing**: `dsh --profile <probe> --dump-config`,
103
+ which prints the composed tree and exits. **It would have caught this before the first restart had I run it
104
+ first.**
105
+
106
+ **No code change and no test count change** — a documentation release. It is versioned rather than left
107
+ unreleased because the incorrect procedure was published in `TROUBLESHOOTING.md`, and a user following it would
108
+ break their client.
109
+
110
+ **56 of 66 release scripts published the wrong text, and this is the fix that makes it impossible to repeat.**
111
+
112
+ ### The defect, found while checking someone else's test plan
113
+
114
+ A test plan written against v0.60.0 reported that its GitHub release notes were *"highly duplicated"* with
115
+ v0.59.0, v0.58.0 and several earlier releases, and concluded they were probably template-generated. **That
116
+ conclusion was right and the cause was mine:**
117
+
118
+ // publish-0600-github.mjs
119
+ /** The release notes reuse the commit message: it already states the cause, the fix and the evidence. */
120
+ const NOTES = fs.readFileSync('E:\\GPT\\scripts\\commit-0103b.txt', 'utf8'); // <- v0.1.3's message
121
+ name: 'v0.60.0 — approval index, fleet sync, service health, tiered storage', // <- hardcoded
122
+
123
+ **The script states the right intention in its own comment and then reads the wrong file.** Measured: **56 of 66
124
+ `publish-*-github.mjs` scripts read that same stale file, and 57 carry that same title** -- four roadmap
125
+ keywords that describe none of those releases.
126
+
127
+ ### What I got wrong twice while investigating
128
+
129
+ **First** I said the `health` module did not exist and that the test plan was testing a fiction. **It exists:**
130
+ `src/agent/health.mjs`, whose own header records the macOS mistake and its fix
131
+ (`darwin: ['/var/log', '/Library/Logs', '/tmp']`). **It was in the code and missing from my CHANGELOG, and I
132
+ read the CHANGELOG as if it were the record.** The test plan was more accurate than my denial of it.
133
+
134
+ **Second**, having been wrong, the honest correction is that the plan is right about `health` and wrong about
135
+ three counts, which are now measured rather than quoted: **89 suites (not 82), 2352 tests (not 2228), 15
136
+ pre-flight checks (not 12), and six aggregation verdicts (not nine)** -- `degraded`, `timeout` and
137
+ `cancelled` do not exist, so the three rows testing them would report false failures.
138
+
139
+ ### The fix is a module, not another corrected copy
140
+
141
+ `scripts/github-release.mjs` **derives both fields from the repository's own record**: the title from the commit
142
+ subject, the body from the CHANGELOG entry for that version. **Neither is written by hand, so neither can be
143
+ copied by accident** -- which was the actual failure mode, since each release was a copy of the previous script
144
+ and the two constants that must change were the two easiest to forget.
145
+
146
+ **It refuses to publish when the CHANGELOG has no entry for the version.** A release whose body cannot be derived
147
+ is a release that would otherwise be published under the previous one's text, which is precisely what happened 56
148
+ times. **It also reads back after writing and compares**, because `PATCH` returning 200 is not evidence that the
149
+ field changed.
150
+
151
+ Verified: a version with no CHANGELOG entry is refused (exit 1); a version with one yields the commit subject as
152
+ its title and a 3024-character body (exit 0, dry run); the exported helpers split CHANGELOG sections correctly,
153
+ return null for an unknown version, and import without side effects.
154
+
155
+ **The token comes from `GITHUB_TOKEN` rather than being embedded**, which all 66 older scripts do.
156
+
157
+ ### The five most-read releases are corrected
158
+
159
+ Titles and bodies rewritten from their own CHANGELOG entries, **each verified by reading the release back**
160
+ rather than by trusting the response:
161
+
162
+ | Release | Title now |
163
+ |---|---|
164
+ | v0.60.0 | `docs(troubleshooting) + feat(script): the lock rule, and an out-of-band session bound` |
165
+ | v0.59.0 | `fix(bin): every published bin can be imported without killing the importing process` |
166
+ | v0.58.0 | `feat(bridge): report what arrived instead of guessing what an image looks like` |
167
+ | v0.57.1 | `docs(bridge): correct a comment that implied a sequence the file does not have` |
168
+ | v0.57.0 | `feat(vision): an image path becomes text evidence, and the credential never touches argv` |
169
+
170
+ Each was 2044 characters -- one identical body -- before the repair.
171
+
172
+ **The remaining 51 are left as they are**, which is a decision rather than an oversight: they are old enough that
173
+ nobody reads them, and the module means the next release is correct without anyone remembering to check.
174
+
175
+ format follows
4
176
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
177
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
178
 
179
+ ## [0.60.0] — 2026-10-16
180
+
181
+ **Two things learned from a client that would not start, neither of which was a crash in this plugin.**
182
+
183
+ ### §3.1 in `TROUBLESHOOTING.md`: check the lock before you check the plugin
184
+
185
+ A launch that leaves **no process at all** is a different failure from one that starts and reports something, and
186
+ the existing §3 procedure will mislead anyone who skips that distinction — it assumes the application *ran*.
187
+
188
+ **The mechanism is one function in `@deepseek-ai/dsh-desktop`'s `lib/main.js`:**
189
+
190
+ ```js
191
+ function claimDesktopSingleInstance(application, focusOwner) {
192
+ if (!application.requestSingleInstanceLock()) {
193
+ application.quit(); // no dialog, no error, no window
194
+ return false;
195
+ }
196
+ ```
197
+
198
+ **A second launch is silently discarded while the first instance holds the lock.** Electron registers it against
199
+ the *application*, not a directory — which is why the two natural remedies both fail, and why the section now says
200
+ so before offering any plugin diagnosis:
201
+
202
+ | What was tried | Why it did nothing |
203
+ |---|---|
204
+ | disable third-party plugins before restarting | the new process quits at the lock, before plugin loading |
205
+ | reinstall the client (new folder, new exe) | a new directory does not release an application-scoped lock |
206
+
207
+ **Deleting `~/.dsh` fixed it — not because anything in it was corrupt, but because the deletion took down the
208
+ process still holding the lock.** The section now leads with the measurement that costs nothing:
209
+
210
+ ```powershell
211
+ Get-Process -Name 'DeepSeek Harness' | Select-Object Id, StartTime
212
+ ```
213
+
214
+ **Processes listed → it is the lock, end them and retry. Nothing listed → the failure really is pre-lock, and §3's
215
+ procedure applies.** The deleted directory carried session history and profile configuration; **`Stop-Process`
216
+ would have cost nothing.**
217
+
218
+ ### `scripts/session-retention.mjs`: the store is bounded, out-of-band, as DSH says it must be
219
+
220
+ `@deepseek-ai/dsh-session-persistence`'s README, under *Known Limitations*:
221
+
222
+ > **No deletion or retention API** — pruning stored sessions is **out-of-band backend maintenance**.
223
+
224
+ **So this is not a workaround for a missing feature: the host states retention is the operator's job.** The
225
+ measurement that motivated it is that **one session reached 18.03 MiB of an 18.07 MiB store**, nothing bounds it,
226
+ and every start reads the store.
227
+
228
+ Three refusals are built in, and each is a deliberate limit rather than an omission:
229
+
230
+ * **The newest session of every workspace is never removed.** DSH writes to it, and *which* process is writing
231
+ cannot be checked reliably from outside — so the rule is the one that needs no knowledge of the running
232
+ process.
233
+ * **`--apply` is required.** The default prints the plan and exits 0. **A tool that removes conversation history
234
+ shows what it will remove before it removes it.**
235
+ * **A directory that is not a session is reported, not touched**, because the layout is read from the filesystem
236
+ rather than assumed.
237
+
238
+ **Verified against the real store:** at a 256 MiB cap it reports *"under the cap — nothing to remove"*; at a
239
+ 1 MiB cap it plans two removals and leaves the 18 MiB live session alone.
240
+
241
+ **No test count change** — both additions are a document and a script, and the script is exercised by hand against
242
+ the store it bounds rather than by a fixture that would not resemble it.
243
+
7
244
  ## [0.59.0] — 2026-10-16
8
245
 
9
246
  **Every published bin can now be imported without killing the importing process — measured, not reasoned about.**
@@ -258,6 +258,162 @@ 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
+
314
+ ### 3.2 Never add a mount row: an installed plugin mounts itself
315
+
316
+ **This section exists because two client failures were caused by following advice that used to be in this
317
+ repository.** A mount row in `cordis.patch.yml` is **not necessary, and adding one breaks the client.**
318
+
319
+ **The plugin mounts itself when the package is installed.** The host composes an installed package from **the
320
+ plugin's own bundle patch** — `package.json`'s `dsh.bundle.patch`, which points at the `cordis.patch.yml` shipped
321
+ *inside this package*. Nothing has to be written into the profile.
322
+
323
+ **Confirmed by experiment.** On a throwaway profile created from the shipped `web` template, `--dump-config`
324
+ reports **exactly one** `w2m` row with the plugin installed and **nothing** added to the profile:
325
+
326
+ ```
327
+ # == @twinsearth/w2m-dsh-plugin
328
+ - id: w2m
329
+ name: '@twinsearth/w2m-dsh-plugin'
330
+ config:
331
+ rabbitUrl: ''
332
+ autoStartAgent: false
333
+ ```
334
+
335
+ **Adding a row inserts a second entry with the same `id`:**
336
+
337
+ ```
338
+ 1260: # == @twinsearth/w2m-dsh-plugin <- automatic
339
+ 1261: - id: w2m
340
+
341
+ 1271: # == ...\profiles\<name>\cordis.patch.yml <- the manual one
342
+ 1272: - id: w2m <- SAME id, twice
343
+ ```
344
+
345
+ **Two rows with one `id` is what made the client stop responding.** The symptom is not a crash and not an
346
+ error message — **the client does not open at all.**
347
+
348
+ #### The rule
349
+
350
+ ```bash
351
+ # Install. That is the whole procedure.
352
+ dsh plugin --profile <name> add @twinsearth/w2m-dsh-plugin@<version>
353
+ ```
354
+
355
+ **Do not edit `cordis.patch.yml` for this plugin.** If you have already added a row, delete that row — **and
356
+ delete the row, not the `~/.dsh` directory.**
357
+
358
+ #### To change configuration, target the existing row
359
+
360
+ **Do not insert.** Address the row that the bundle patch already created:
361
+
362
+ ```yaml
363
+ - id: w2m # a non-insert patch: replaces fields on the existing entry
364
+ config:
365
+ autoStartAgent: true
366
+ ```
367
+
368
+ **An `insert:` is an append.** Appending a row whose `id` already exists is the failure described above.
369
+
370
+ #### `Add-Content` onto this file is always wrong
371
+
372
+ It is a YAML **list**, so the file's root is a sequence:
373
+
374
+ ```
375
+ Error: dsh: failed to parse overlay ...\cordis.patch.yml:
376
+ YAMLException: end of the stream or a document separator is expected (6:1)
377
+
378
+ 4 | []
379
+ 6 | - insert:
380
+ -----^
381
+ ```
382
+
383
+ `[]` is a complete document; a `- insert:` list after it is a second one. **Rewrite the file with a list root;
384
+ never append to it.** A freshly created profile ships `[]`, so this bites on the first edit.
385
+
386
+ #### Verify before you restart, when you can
387
+
388
+ `--dump-config` **composes the configuration and exits without starting an application**, which makes it the
389
+ safe way to check an edit:
390
+
391
+ ```bash
392
+ dsh --profile <name> --dump-config # then search the output for this package's name
393
+ ```
394
+
395
+ **The desktop profile is the one case where this is unavailable.** The CLI refuses it
396
+ (`rejectElectronProfile`): Desktop manages that profile exclusively. **So for the desktop profile there is no
397
+ safe preview — the only protection is writing the row correctly, which in practice means not writing one at all,
398
+ because the plugin mounts itself.**
399
+
400
+ If you must test a row, **create a separate profile and try it there first**:
401
+
402
+ ```bash
403
+ dsh probe --from-default-profile web --dump-config
404
+ dsh plugin --profile probe add @twinsearth/w2m-dsh-plugin@<version>
405
+ dsh --profile probe --dump-config # count the rows naming this package: expect exactly one
406
+ ```
407
+
408
+ **The measured sequence, so the expectation is on record:**
409
+
410
+ | State | `id: w2m` rows |
411
+ |---|---|
412
+ | profile created, plugin not installed | 0 |
413
+ | **plugin installed, nothing else done** | **1** |
414
+ | a correct `insert:` row added as well | **2** ← the failure |
415
+ | that row removed | **1** |
416
+
261
417
  ---
262
418
 
263
419
  ## 4. A machine is listed but never answers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.59.0",
3
+ "version": "1.0.1",
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.59.0",
4
+ "version": "1.0.1",
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": [
@@ -58,7 +58,7 @@
58
58
 
59
59
  import { basename, extname } from 'node:path';
60
60
 
61
- import { hasPathSeparator, isRelativePath, resolveCommand, sameFile } from './resolve.mjs';
61
+ import { bundledExecutable, hasPathSeparator, isRelativePath, resolveCommand, sameFile } from './resolve.mjs';
62
62
 
63
63
  /**
64
64
  * Parse `--allowed-commands`.
@@ -474,6 +474,24 @@ function executableIdentity(argv0, prefix0, options) {
474
474
  return { kind: 'no-match', detail: null };
475
475
  }
476
476
  if (sameFile(resolvedArgv, resolvedPrefix)) return { kind: 'match', detail: null };
477
+
478
+ /**
479
+ * Second identity path, and the one that matters on a machine where the bundled tree is not on PATH.
480
+ *
481
+ * A bare entry names a tool; **the bundled copy of that tool is that tool**, whether or not PATH happens to
482
+ * point at it. This is what admits `process.execPath` for the entry `node` without weakening anything: it
483
+ * **only ever** returns a file under `<deps>`, so `E:\Temp\evil\node.exe` is still refused, and it does not
484
+ * touch the search order, so `keeps PATH ahead of the bundled runtime` still holds.
485
+ *
486
+ * It also fixes a real case rather than a test-only one: **DSH runs on the bundled interpreter and does not
487
+ * put its directory on PATH**, so an allow-list entry of `node` could not be satisfied by the very interpreter
488
+ * the host executes.
489
+ */
490
+ if (!prefixIsPath && argvIsPath) {
491
+ const bundled = bundledExecutable(prefix0, options.env ?? process.env);
492
+ if (bundled !== null && sameFile(argv0, bundled)) return { kind: 'match', detail: null };
493
+ }
494
+
477
495
  return {
478
496
  kind: 'path-mismatch',
479
497
  detail:
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { accessSync, constants, realpathSync, statSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
- import { delimiter, isAbsolute, join } from 'node:path';
21
+ import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
22
22
 
23
23
  /** The DSH runtime directory layout we fall back to (task-8, measured). */
24
24
  export const DSH_RUNTIME_RELATIVE = join('dsh-runtimes', 'dsh-primary-runtime', 'dependencies');
@@ -34,22 +34,72 @@ export function dshHome(env = process.env) {
34
34
  return explicit ?? join(homedir(), '.dsh');
35
35
  }
36
36
 
37
+ /**
38
+ * The dependency directory implied by the interpreter that is running this code, or `null`.
39
+ *
40
+ * ## Why this exists, and why it is the first fallback
41
+ *
42
+ * `DSH_HOME/dsh-runtimes/<runtime>/dependencies` was the only layout this module knew. **The application moved
43
+ * and that assumption stopped holding**: the runtime now ships inside the bundle at
44
+ * `E:\DS\resources\runtime\primary-runtime\dependencies`, so `$DSH_HOME` contains no `dsh-runtimes/` at all, and
45
+ * every lookup that fell back to it found nothing.
46
+ *
47
+ * **The measurement that located it:** twelve end-to-end tests refused with `COMMAND_EXECUTABLE_PATH_MISMATCH`.
48
+ * They dispatch `[process.execPath, '-e', ...]` against an allow-list entry of the bare name `node`, so the
49
+ * identity check resolves `node` and compares the two files. `process.execPath` is the **bundled** interpreter --
50
+ * `...\dependencies\node\bin\node.exe` -- while the bare name resolved to whatever PATH held. Two different
51
+ * files, so the refusal was correct and the lookup was wrong.
52
+ *
53
+ * **So the fix is discovery rather than another hardcoded path**: a bundled interpreter lives at
54
+ * `<deps>/node/bin/node`, which means its own path *states* where `<deps>` is. That holds wherever the
55
+ * application is installed, and it cannot go stale when the installation moves again.
56
+ *
57
+ * @returns {string|null}
58
+ */
59
+ function depsDirFromExecutable() {
60
+ const exe = process.execPath;
61
+ if (typeof exe !== 'string' || exe === '') return null;
62
+ const binDir = dirname(exe);
63
+ // `<deps>/node/bin/node[.exe]` is the shipped shape on every platform.
64
+ if (basename(binDir) !== 'bin' || basename(dirname(binDir)) !== 'node') return null;
65
+ const deps = dirname(dirname(binDir));
66
+ try {
67
+ return statSync(deps).isDirectory() ? deps : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
37
73
  /**
38
74
  * Absolute path of the bundled DSH dependency directory, or `null` when absent.
39
75
  *
40
- * `DSH_RUNTIME_DEPS` overrides the guess, which keeps tests hermetic.
76
+ * Candidates are tried in order and the **first that exists** wins:
77
+ *
78
+ * 1. `DSH_RUNTIME_DEPS`, the explicit override that keeps tests hermetic;
79
+ * 2. `$DSH_HOME/dsh-runtimes/<runtime>/dependencies`, the shipped layout this module documented;
80
+ * 3. the directory implied by `process.execPath`, which is how the bundled layout is found now that the
81
+ * application carries its runtime inside the bundle instead of under the home directory.
82
+ *
83
+ * Returning the first *existing* candidate rather than a single computed path is the part that matters: a
84
+ * stale first guess must not shadow a real second one.
41
85
  *
42
86
  * @param {Record<string,string|undefined>} [env]
43
87
  * @returns {string|null}
44
88
  */
45
89
  export function dshDependenciesDir(env = process.env) {
46
90
  const override = env.DSH_RUNTIME_DEPS && env.DSH_RUNTIME_DEPS.trim() !== '';
47
- const dir = override ? env.DSH_RUNTIME_DEPS : join(dshHome(env), DSH_RUNTIME_RELATIVE);
48
- try {
49
- return statSync(dir).isDirectory() ? dir : null;
50
- } catch {
51
- return null;
91
+ const candidates = [
92
+ override ? env.DSH_RUNTIME_DEPS : join(dshHome(env), DSH_RUNTIME_RELATIVE),
93
+ depsDirFromExecutable(),
94
+ ].filter((dir) => typeof dir === 'string' && dir !== '');
95
+ for (const dir of candidates) {
96
+ try {
97
+ if (statSync(dir).isDirectory()) return dir;
98
+ } catch {
99
+ // Try the next candidate; an absent guess is not an error.
100
+ }
52
101
  }
102
+ return null;
53
103
  }
54
104
 
55
105
  /**
@@ -123,6 +173,53 @@ export function bundledBinDirs(env = process.env) {
123
173
  });
124
174
  }
125
175
 
176
+ /**
177
+ * The file a bare tool name would name *inside DSH's bundled runtime*, or `null`.
178
+ *
179
+ * ## Why the identity check needs this and the search order must not
180
+ *
181
+ * `resolveCommand` keeps PATH ahead of the bundled tree, and a test guards that precedence
182
+ * (`keeps PATH ahead of the bundled runtime`). **The two are not in conflict**: PATH decides which file a
183
+ * *name* means, while this answers a different question -- *is this path the bundled copy of that tool?*
184
+ *
185
+ * **The measurement that required it:** twelve end-to-end tests refused with
186
+ * `COMMAND_EXECUTABLE_PATH_MISMATCH`. They dispatch `[process.execPath, '-e', ...]` against an allow-list entry
187
+ * of the bare name `node`. `process.execPath` is the bundled interpreter
188
+ * (`<deps>\node\bin\node.exe`), but that directory is **not on this machine's PATH**, so the bare name resolved
189
+ * to an unrelated `node.exe` shipped by another application and the two files differed.
190
+ *
191
+ * **The refusal was correct and the identity was wrong.** An absolute path that *is* the bundled copy of the
192
+ * tool the entry names is the tool the entry names -- which is exactly the rule the header states, and the
193
+ * reason `E:\Temp\evil\node.exe` must still be refused: that is a different directory, and this only ever
194
+ * returns files under `<deps>`.
195
+ *
196
+ * @param {string} name Bare command name, e.g. `node`.
197
+ * @param {Record<string,string|undefined>} [env]
198
+ * @returns {string|null} Absolute path when the bundled copy exists.
199
+ */
200
+ export function bundledExecutable(name, env = process.env) {
201
+ if (typeof name !== 'string' || name === '' || hasPathSeparator(name)) return null;
202
+ const depsDir = dshDependenciesDir(env);
203
+ if (depsDir === null) return null;
204
+ const isWin = process.platform === 'win32';
205
+ const exts = isWin ? ['', ...WIN_EXTENSIONS] : [''];
206
+ const bases = [
207
+ join(depsDir, name, 'bin'),
208
+ join(depsDir, name),
209
+ ];
210
+ for (const base of bases) {
211
+ for (const ext of exts) {
212
+ const candidate = join(base, name + ext);
213
+ try {
214
+ if (statSync(candidate).isFile()) return candidate;
215
+ } catch {
216
+ /* keep looking */
217
+ }
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
126
223
  /**
127
224
  * Resolve `argv[0]` the way the capability probe resolves a tool.
128
225
  *
@@ -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.59.0';
211
+ const PLUGIN_VERSION = '1.0.1';
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;