@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 +238 -1
- package/TROUBLESHOOTING.md +156 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/agent/allowed-commands.mjs +19 -1
- package/src/agent/resolve.mjs +104 -7
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,246 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable changes to this project are documented here.
|
|
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.**
|
package/TROUBLESHOOTING.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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:
|
package/src/agent/resolve.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
*
|
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 = '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;
|