@twinsearth/w2m-dsh-plugin 0.41.0 → 0.43.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 CHANGED
@@ -4,6 +4,131 @@ 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.43.0] — 2026-10-15
8
+
9
+ **A machine stuck in its own state directory — and the parameter that was accepted, hashed, and ignored.**
10
+
11
+ ### What a deployment test on MacMini actually found
12
+
13
+ Running the project's own test plan against MacMini produced eight dispatches that all failed, and **none of
14
+ them failed for the reason the answers said.** The sequence, and what each answer meant:
15
+
16
+ | Dispatched | Answer | What it actually meant |
17
+ |---|---|---|
18
+ | `node --test --test-reporter=spec test/` | `COMMAND_REPARSING_ARGUMENT` | correct refusal — the flag is not on the allow-list |
19
+ | `bash -lc "…"` | `COMMAND_NOT_ALLOWED` | correct refusal |
20
+ | `node --test <files>` | `exit 1` | **the files were not found** — the test never ran |
21
+ | `cwd_rel: "local"` | `status: crashed`, `PATH_INVALID` | **the resolved directory did not exist** |
22
+
23
+ **`PATH_INVALID` is a warning about a missing EXECUTABLE, and it was reporting a missing DIRECTORY.** Nothing
24
+ in the answer said so. Establishing that took every one of those eight dispatches, and the cost was not the
25
+ dispatches — it was that **every one of them looked like a test failure.**
26
+
27
+ ### The cause, and it is a configuration default rather than a bug
28
+
29
+ `bin/w2m-localside.mjs:152` is `const project = flags.project ?? process.cwd()`. A `localside` started
30
+ without `--project` takes **its own working directory** as the project root, and the packaged DSH starts it
31
+ from `~/.dsh/xclient`. So that machine could run **only inside `~/.dsh/xclient`** — and nothing about the
32
+ answer pointed at the cause.
33
+
34
+ ### `cwd_rel` travelled, was hashed, was validated, and was never consumed
35
+
36
+ `w2m_run` has always accepted `cwd_rel`. The relay normalizes it, folds it into `command_hash`, and stores it
37
+ on the task. The agent **resolves** it through `resolveProjectCwd` — and then, when the resolution failed,
38
+ **logged a warning and ran anyway with the unvalidated relative path.**
39
+
40
+ **The guard computed its own refusal and discarded it.** That is worse than no guard, because the guard is
41
+ what a reader would cite as coverage.
42
+
43
+ ### The escape is now named, opt-in, and reported
44
+
45
+ `resolveProjectCwd(root, cwdRel, { allowOutside })` — **`false` unless a caller asks, carried on the offer as
46
+ `cwd_outside_project`, and off for every value that is not exactly `true`**, since it arrives over the
47
+ wire. The resolved absolute directory is returned as `outside_project`, **so a result says where the command
48
+ ran instead of leaving a reader to assume.**
49
+
50
+ **This does not widen what may be RUN.** The command allow-list, the executable-path check and the
51
+ argument-reparse refusal all still apply — **and three of them were observed refusing correctly during this
52
+ same session.** It changes *where*, not *what*, and only when asked.
53
+
54
+ ### A guard caught me, and the fix was the name rather than the guard
55
+
56
+ The parameter was first called `allow_cwd_outside_project`, and `tool-cards.test.mjs` failed on it:
57
+
58
+ > `w2m_run` declares `allow_cwd_outside_project`, which names an allow-list — **a tool that can set one of
59
+ > these widens what the fleet will do, and that decision belongs to an operator editing configuration on the
60
+ > machine itself, never to a model**
61
+
62
+ **It was right to fire, and it was firing on the name rather than the behaviour.** The parameter does not set
63
+ an allow-list: **it changes WHERE a command runs, not WHAT may run** — the command allow-list, the
64
+ executable-path check and the argument-reparse refusal all still apply, and three of them were observed
65
+ refusing in this same session.
66
+
67
+ **So the guard was left alone and the parameter was renamed** to `cwd_outside_project`. Relaxing a guard that
68
+ protects the fleet, in order to land a feature of my own, is the one move this project has consistently
69
+ refused — and **a guard that fires on a word is still a guard that made me justify the word.**
70
+
71
+ ### What the session measured about the security controls
72
+
73
+ Four refusals, all correct, all from real dispatches:
74
+
75
+ * `COMMAND_NOT_ALLOWED` — `node -e` on a machine whose allow-list omits it, and `bash -lc`
76
+ * `COMMAND_REPARSING_ARGUMENT` — `node --test --test-reporter=spec` (a flag not declared) **and** an attempt
77
+ to smuggle `--eval` behind `--test`, which the plugin caught as *re-parseable* rather than as unknown
78
+ * `COMMAND_EXECUTABLE_PATH_MISMATCH` — `/bin/sh` where the allow-list names `node`
79
+ * `PATH_INVALID` — the one refusal that was **not** a security control, and the one whose message misled
80
+
81
+ **12 new tests, 0 failures.**
82
+
83
+ ## [0.42.0] — 2026-10-15
84
+
85
+ **The README catches up with 62 versions — and gathering the numbers found a defect that had survived all of them.**
86
+
87
+ ### The progress section, in both languages
88
+
89
+ The README's newest version reference was **v0.4.0**, thirteen times, and its install command pinned a
90
+ **v0.6.3** tarball. **Thirty-four versions of work had no record in the file a newcomer reads first.**
91
+
92
+ There is now a **Progress** section (with a matching 进度 section) built from the CHANGELOG rather than from
93
+ memory: **62 versions since 0.8.0, 11 tools, 79 suites, 2199 tests, 12 pre-flight checks, 0 failures.** It
94
+ carries the three properties the project set out to have, the roadmap's **delivered / partial** state per
95
+ version, what the reachability work found, the measured-on-real-machines table, and an explicit **"this is not
96
+ 1.0.0"**.
97
+
98
+ **The seven accepted orphans are listed with what would remove each one**, and the **three that are defects are
99
+ named as defects** rather than folded in with the four libraries — because the import graph cannot tell them
100
+ apart, and only a written reason can.
101
+
102
+ ### `layer-check` printed "clean" and exited 1
103
+
104
+ Gathering the numbers meant running the checks and reading their output and their status side by side, and two
105
+ of them contradicted each other:
106
+
107
+ ```
108
+ clean: every cross-layer edge is declared
109
+ exit=1
110
+ ```
111
+
112
+ **The clean path called `process.exit(1)`.** Every reader that trusts the exit code — a shell, CI, a script,
113
+ `&&` — **read a passing check as a failing one.**
114
+
115
+ **The pre-flight never noticed, and the reason is the interesting part: its own step reads the PRINTED TEXT
116
+ rather than the status.** A check can therefore be wrong for a hundred versions while the gate over it stays
117
+ green. It now exits `0`, with the failure paths still distinct (`2` for a violation, `3` for unexplained), and
118
+ a regression test asserts **the status rather than the message** — because a test that read the message would
119
+ have passed against the broken version, which is exactly what happened.
120
+
121
+ ### Also corrected
122
+
123
+ * the install instructions now say **how to verify you got the version you asked for**, because this
124
+ repository's own profile installed from a pinned tarball and **fell 30+ versions behind**. A successful
125
+ install and a current install are two different things.
126
+ * both language halves now name **`pnpm`** as the fallback where `npm` is absent — which is the case in the
127
+ packaged DSH runtime this was tested against.
128
+ * one dead in-page anchor, from assuming GitHub's slugger keeps an em dash. It does not.
129
+
130
+ **2 new tests, 0 failures.**
131
+
7
132
  ## [0.41.0] — 2026-10-15
8
133
 
9
134
  **`field-authority.mjs` has a caller. The correction from 0.29.0 lands, six versions after the claim.**
package/README.md CHANGED
@@ -14,6 +14,71 @@ English | [中文](#中文说明)
14
14
 
15
15
  ---
16
16
 
17
+ ## Progress — where this actually is
18
+
19
+ **Current: `0.41.0`.** 62 versions since `0.8.0`. The number that matters more than the version:
20
+ **11 tools, 79 suites, 2199 tests, 12 pre-flight checks, 0 failures.**
21
+
22
+ ### The three properties this project set out to have
23
+
24
+ | Property | State | What it means concretely |
25
+ |---|---|---|
26
+ | **分模块** (modular) | **enforced** | every file belongs to exactly one declared layer; `layer-check` fails on an undeclared edge |
27
+ | **分层级** (layered) | **enforced** | six ranked layers and an **allow-list** of edges — not a rule that can be interpreted, a list that can be read |
28
+ | **分权限** (tiered authority) | **built, opt-in** | four bands, escalation moves **exactly one band**, nothing destructive auto-settles. **Off by default** — see the gap below |
29
+
30
+ ### Roadmap
31
+
32
+ | Version | Deliverable | State |
33
+ |---|---|---|
34
+ | **v4.8.0** | field-level acceptance, MMP P1/P2, lineage | **delivered** — `w2m_memory` accepts a received CAT7 block **field by field at the actor's level**; a field that costs more than the actor has is **omitted, not nulled**; a block naming an ancestor the caller cannot produce is **refused, never re-rooted** |
35
+ | **v4.9.0** | approval queue | **partial** — the queue, the escalation bridge and the raise-limiter are wired and reachable by an operator through `w2m_approvals`. **The "front door" named in the roadmap was never built**: `approval-index.mjs` imports `authority.mjs` and nothing else |
36
+ | **v4.10.0** | committee governance | **delivered** — seven phases, weighted votes, **a tie is refused rather than broken**, and a sealed decision is **retrievable with its dissent and its reasons** after the council that produced it is gone |
37
+
38
+ ### What the reachability work found, because it is the honest part
39
+
40
+ Three mechanisms were shipped in this span **fully tested and reachable by nothing** — `field-authority`,
41
+ `approval-gate`, and `council`. Two were **described as delivered** while nothing ran them. A single `grep`
42
+ disproved one of them.
43
+
44
+ There is now a **gate**: a module nothing reaches makes the pre-flight **red** unless it appears in
45
+ `scripts/accepted-orphans.mjs` **with a reason and a statement of what would remove it**. An acceptance that
46
+ outlives its cause also fails. **Seven remain**:
47
+
48
+ | Module | Kind | What would remove it |
49
+ |---|---|---|
50
+ | `health.mjs` | **defect** | the agent calling it on a schedule |
51
+ | `log-bounds.mjs` | **defect** | the same — it is the answer to a **measured 40× log growth** |
52
+ | `storage.mjs` | **defect** | a tool writing through its undelatable tiers |
53
+ | `fleet-scale.mjs`, `fleet-state.mjs`, `exit-channel.mjs`, `test-report.mjs` | library | a caller, when one is wanted |
54
+
55
+ **The defect class is named as a defect rather than folded in with the libraries**, because the two are
56
+ indistinguishable from the import graph alone and only a written reason can separate them.
57
+
58
+ ### The gap that matters most right now
59
+
60
+ **The plugin this machine runs is 30+ versions behind this source tree.** Every source-level conclusion about
61
+ the running runtime is void until it is upgraded — which is one command, and it is in
62
+ [Install](#3-the-dsh-plugin).
63
+
64
+ ### Measured on real machines
65
+
66
+ | Fact | Evidence |
67
+ |---|---|
68
+ | a **seven-phase council** runs on macOS | `exit 0` on MacMini: phases 7, dissent retained, recount reproduced |
69
+ | **field-level acceptance** holds on macOS | `exit 0` on MacMini: level 0 cannot reach `evidence`, level 70 can, tampering caught, a missing ancestor refused rather than re-rooted |
70
+ | the fleet is **3 usable machines of 5 paired** | and `fleet-scale.mjs` **refuses to extrapolate** from five |
71
+ | **`P2P_PUNCH_FAILED`** | **UNVERIFIED.** Three hypotheses measured and excluded (BLAKE2s-256, the wider primitive set, version skew). **The plugin that raises it runs 0.9.5** — upgrading is the next experiment |
72
+
73
+ ### Not claimed
74
+
75
+ **This is not 1.0.0.** `docs/1.0.0-ACCEPTANCE.md` sorts all 30 criteria into **ENFORCED / MEASURED /
76
+ UNVERIFIED** by a rule rather than an opinion, names **seven claims this work had to retract**, and states
77
+ plainly that four of its own closing items are undone. It is bound to the pre-flight by a test, so a criterion
78
+ cannot quietly become unenforced while the file keeps claiming otherwise.
79
+
80
+ ---
81
+
17
82
  ## The problem
18
83
 
19
84
  You have a Windows desktop and a Mac laptop. You are working on one project. You
@@ -250,9 +315,18 @@ dsh plugin --profile <profile-name> add @twinsearth/w2m-dsh-plugin
250
315
 
251
316
  ```bash
252
317
  dsh plugin --profile <profile-name> add \
253
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.6.3/twinsearth-w2m-dsh-plugin-0.6.3.tgz
318
+ https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.41.0/twinsearth-w2m-dsh-plugin-0.41.0.tgz
254
319
  ```
255
320
 
321
+ This form **pins a version by construction**, which is both its virtue and the trap: a
322
+ profile that installs this way stays on that version forever unless somebody edits the
323
+ line. **This repository's own profile did exactly that and fell 30+ versions behind** —
324
+ so if you use it, re-check the number against the badge at the top of this file.
325
+
326
+ **No `npm`?** Some hosted runtimes (including the packaged DSH) ship `node` and `pnpm`
327
+ but not `npm`. `pnpm add @twinsearth/w2m-dsh-plugin@0.41.0 --save-exact` installs the
328
+ identical tarball from the identical registry.
329
+
256
330
  **From the repository** — the form a marketplace's one-click installer uses (it clones
257
331
  the repo, builds nothing, and registers `cordis.patch.yml`):
258
332
 
@@ -270,6 +344,14 @@ answer, not three.
270
344
  > listed in `package.json` but never composed is the failure this project hit once
271
345
  > already — see `cordis.patch.yml`'s header for what that looked like.
272
346
 
347
+ **Then check the version you actually got**, because a successful install and a current
348
+ install are two different things:
349
+
350
+ ```
351
+ w2m_status → actor_authority and approvals should be present (0.9.5 has neither)
352
+ tool list → w2m_council should be present (it arrived in 0.37.0; anything older lacks it)
353
+ ```
354
+
273
355
  Then give it the relay URL in that profile's `cordis.patch.yml` — or leave
274
356
  `rabbitUrl` empty to use the shared server:
275
357
 
@@ -532,6 +614,10 @@ Read this before exposing the relay to anything.
532
614
 
533
615
  This project's rule is that claims carry their evidence.
534
616
 
617
+ > **Current state lives in [Progress](#progress-where-this-actually-is) above.** What follows is the
618
+ > **v0.4.0 record**, kept because it is the measurement the direct path still rests on — not because it
619
+ > describes what ships today. The version is now `0.41.0`.
620
+
535
621
  ### v0.4.0 — the direct path is the default, and here is what that is worth
536
622
 
537
623
  v0.3.9 shipped the P2P transport and admitted in its own §8 that nothing read
@@ -778,6 +864,60 @@ When something does not work, start from the symptom: [TROUBLESHOOTING.md](TROUB
778
864
 
779
865
  **同一个 DeepSeek 账号,一条指令,让在线的 Windows 与 Mac 跑同一个项目。**
780
866
 
867
+ ### 进度 —— 它现在到底到哪一步了
868
+
869
+ **当前版本:`0.41.0`**。自 `0.8.0` 以来共 **62 个版本**。比版本号更重要的数字是:
870
+ **11 个工具、79 个测试套件、2199 个用例、12 项预检、0 失败。**
871
+
872
+ #### 三项目标本来的属性
873
+
874
+ | 属性 | 状态 | 具体含义 |
875
+ |---|---|---|
876
+ | **分模块** | **已强制** | 每个文件属于且只属于一个已声明的层;出现未声明的依赖边,`layer-check` 直接失败 |
877
+ | **分层级** | **已强制** | 六个带序号的层,以及一张**允许清单**——不是一条可以被解读的规则,而是一张可以被读的清单 |
878
+ | **分权限** | **已建成,需手动开启** | 四个权限带、升级**恰好一级**、破坏性操作**永不自动放行**。**默认关闭**,见下方缺口 |
879
+
880
+ #### 路线图
881
+
882
+ | 版本 | 交付物 | 状态 |
883
+ |---|---|---|
884
+ | **v4.8.0** | 字段级接受、MMP P1/P2、lineage | **已交付** —— `w2m_memory` 接收来自其他机器的 CAT7 认知块,**按字段、按接收方的权限级别**逐项接受;接收方买不起的字段**是「缺席」,不是 null**;祖先无法产出时**拒绝,绝不重新生根** |
885
+ | **v4.9.0** | 审批队列 | **部分完成** —— 队列、升级桥、发起限流器都已接线,操作者可通过 `w2m_approvals` 处理。**但路线图里写的那道「前门」从未构建**:`approval-index.mjs` 只导入 `authority.mjs`,别无其他 |
886
+ | **v4.10.0** | 委员会治理 | **已交付** —— 七个阶段、加权投票、**平票是拒绝而不是打破**;封印的决定在产生它的委员会消失之后,**仍可连异议和异议的理由一起取回** |
887
+
888
+ #### 可达性检查发现了什么(这部分才是诚实的部分)
889
+
890
+ 这一跨度里有**三个机制被完整实现、完整测试、却没有任何东西能到达**——`field-authority`、`approval-gate`、`council`。其中**两个在被描述为「已交付」时,没有任何东西在跑它们**。一条 `grep` 就推翻了其中一个。
891
+
892
+ 现在有**门禁**了:一个没人能到达的模块会让预检**变红**,除非它出现在 `scripts/accepted-orphans.mjs` 里,**并写明理由和「什么能让它下榜」**。已经修好的条目若还留在清单上,同样失败。**目前还剩 7 个**:
893
+
894
+ | 模块 | 性质 | 什么能让它下榜 |
895
+ |---|---|---|
896
+ | `health.mjs` | **缺陷** | agent 按计划调用它 |
897
+ | `log-bounds.mjs` | **缺陷** | 同上——它是对**实测到的 40 倍日志增长**的答案 |
898
+ | `storage.mjs` | **缺陷** | 有工具真正通过它的「不可删除」层写入 |
899
+ | `fleet-scale.mjs`、`fleet-state.mjs`、`exit-channel.mjs`、`test-report.mjs` | 库 | 需要时给它们调用方 |
900
+
901
+ **「缺陷」这一类被单独命名,而不是和「库」混在一起**——因为仅凭导入图这两者无法区分,**只有写下来的理由能分开它们**。
902
+
903
+ #### 眼下最要紧的那个缺口
904
+
905
+ **本机实际运行的插件比这份源码落后 30 多个版本。** 在升级之前,**任何关于运行时行为的源码级结论都作废**。升级只需一条命令,见[快速开始](#快速开始)第 3 步。
906
+
907
+ #### 在真机上实测到的
908
+
909
+ | 事实 | 证据 |
910
+ |---|---|
911
+ | **七阶段委员会**能在 macOS 上跑通 | MacMini `exit 0`:7 个阶段、异议被保留、`recount` 复现成功 |
912
+ | **字段级接受**在 macOS 上成立 | MacMini `exit 0`:级别 0 拿不到 `evidence`、级别 70 拿得到、篡改被抓、**缺祖先时拒绝而不重新生根** |
913
+ | 编队是**5 台配对中 3 台可用** | 而 `fleet-scale.mjs` 因此**拒绝外推** |
914
+ | **`P2P_PUNCH_FAILED`** | **未验证。** 三个假设已测量并排除(BLAKE2s-256、更广的原语集、版本偏差)。**抛出它的是跑着 0.9.5 的插件**——升级就是下一个实验 |
915
+
916
+ #### 不作声明
917
+
918
+ **这不是 1.0.0。** `docs/1.0.0-ACCEPTANCE.md` 把全部 30 条准则按**一条规则**(而非观点)分成
919
+ **已强制 / 已测量 / 未验证**三类,列出**这一跨度不得不撤回的 7 条声明**,并明说它自己收尾清单里的四项尚未完成。它被一个测试**绑定到预检**,所以**一条准则不可能在文件仍声称它被强制的同时悄悄变成未被强制**。
920
+
781
921
  ### 为什么需要它
782
922
 
783
923
  DSH 目前**不能**跨机器协作,而且不是疏忽:
@@ -817,8 +957,21 @@ node bin/w2m-localside.mjs --rabbit http://<中继地址>:8787 --pair PAIR-XXXXX
817
957
  --allowed-commands '["node --test","git status --porcelain"]'
818
958
 
819
959
  # 3) 装 DSH 插件
820
- dsh plugin --profile <profile-name> add \
821
- https://github.com/TwinsEarth/dsh-windows2macos/releases/download/v0.6.3/twinsearth-w2m-dsh-plugin-0.6.3.tgz
960
+ # v0.4.0 起可从 npm 装(比逐个指定 release 的 tgz 更好:不会锁死在某个旧版本上)。
961
+ # 本仓库自己的 profile 就一直锁在一个本地 0.9.5 的 tgz 上,结果落后了 30 多个版本。
962
+ npm install @twinsearth/w2m-dsh-plugin@latest
963
+ # 或者,如果你想钉死一个具体版本:
964
+ # npm install @twinsearth/w2m-dsh-plugin@0.41.0
965
+ #
966
+ # 没有 npm 的托管运行时(DSH 打包版就是这种)用 pnpm 即可,同一个 registry、同一份 tarball:
967
+ # pnpm add @twinsearth/w2m-dsh-plugin@0.41.0 --save-exact
968
+ ```
969
+
970
+ 装完后**重启 DSH**,然后确认版本对上了——这一步别跳过,因为**本仓库自己就在这里栽过**:
971
+
972
+ ```
973
+ w2m_status → 应能看到 actor_authority 与 approvals 两个字段(0.9.5 没有)
974
+ 工具列表 → 应能看到 w2m_council(旧版只有 8 个工具)
822
975
  ```
823
976
 
824
977
  然后在 profile 的 `cordis.patch.yml` 里给插件 `rabbitUrl`(留空即用共享服务器)与 `operatorToken`,重启 DSH,即可直接用自然语言指挥:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.41.0",
3
+ "version": "0.43.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",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
117
117
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
118
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * The Localside agent: everything between "Rabbit hands us an offer" and
3
3
  * "Rabbit has our envelope".
4
4
  *
@@ -2977,7 +2977,7 @@ export function createAgent(options) {
2977
2977
  for (const stage of plannedStages) {
2978
2978
  heartbeat.setPhase('running', 50);
2979
2979
 
2980
- const stageCwdOutcome = resolveProjectCwd(project, stage.cwd_rel);
2980
+ const stageCwdOutcome = resolveProjectCwd(project, stage.cwd_rel, { allowOutside: offer.cwd_outside_project === true });
2981
2981
  if (!stageCwdOutcome.ok) {
2982
2982
  // A stage whose cwd is outside the project is a refusal of the whole chain: continuing
2983
2983
  // would run later stages against a working directory the caller never asked for.
package/src/agent/git.mjs CHANGED
@@ -584,19 +584,38 @@ export async function collectPostState(options) {
584
584
  * Containment is decided with `path.relative`, which (unlike a string prefix
585
585
  * test) is correct on Windows where drive letters and path case vary.
586
586
  *
587
+ * ## Why there is now an explicit way out, and why it is opt-in
588
+ *
589
+ * This guard was written because a caller should not be able to point a machine at a directory the
590
+ * operator never designated as the project. **Measured, that turned out to make the agent unusable for
591
+ * the one thing a fleet is for**: a machine whose localside was started from `~/.dsh/xclient` (its own
592
+ * state directory, which is the default when `--project` is not passed) **can only ever run inside
593
+ * `~/.dsh/xclient`**. Dispatching `node --test test/x.test.mjs` to it then fails with `PATH_INVALID`,
594
+ * because the resolved directory does not exist — **and nothing about the answer says why.** Eight
595
+ * dispatches went into establishing that.
596
+ *
597
+ * So the escape exists and is **named**: `allowOutside`. It is `false` unless a caller asks, it is
598
+ * carried on the offer rather than inferred, and the resolved absolute path is reported back so the
599
+ * result says the command ran outside the project. **A named, auditable escape is worth more than a
600
+ * guard that quietly ignores its own outcome** — the previous version computed this refusal and then
601
+ * ran anyway with the *unvalidated* relative path.
602
+ *
587
603
  * @param {string} projectRoot Absolute project root.
588
604
  * @param {string} cwdRel Forward-slash relative path from the offer.
589
- * @returns {{ok: true, cwd: string, cwd_rel: string}|{ok: false, reason: string}}
605
+ * @param {{allowOutside?: boolean}} [options]
606
+ * @returns {{ok: true, cwd: string, cwd_rel: string, outside_project: boolean}|{ok: false, reason: string}}
590
607
  */
591
- export function resolveProjectCwd(projectRoot, cwdRel) {
608
+ export function resolveProjectCwd(projectRoot, cwdRel, options = {}) {
609
+ const allowOutside = options.allowOutside === true;
592
610
  const root = resolve(projectRoot);
593
611
  const normalized = normalizeRelPath(cwdRel);
594
612
  const target = resolve(root, normalized.split('/').join(sep));
595
613
  const rel = relative(root, target);
596
- if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) {
614
+ const escapes = rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel);
615
+ if (escapes && !allowOutside) {
597
616
  return { ok: false, reason: 'CWD_OUTSIDE_PROJECT' };
598
617
  }
599
- return { ok: true, cwd: target, cwd_rel: normalizeRelPath(rel) };
618
+ return { ok: true, cwd: target, cwd_rel: normalizeRelPath(rel), outside_project: escapes };
600
619
  }
601
620
 
602
621
  /**
@@ -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.41.0';
211
+ const PLUGIN_VERSION = '0.43.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;
@@ -3571,6 +3571,19 @@ export async function apply(ctx, config = {}) {
3571
3571
  description: 'Working directory relative to the project root, with forward slashes. Defaults to ".".',
3572
3572
  default: '.',
3573
3573
  },
3574
+ cwd_outside_project: {
3575
+ type: 'boolean',
3576
+ default: false,
3577
+ description:
3578
+ 'v0.43.0. Let `cwd_rel` resolve OUTSIDE the target machine\'s project root. OFF by default, and ' +
3579
+ 'that guard exists for a reason: a machine should not be pointed at a directory the operator ' +
3580
+ 'never designated. It is needed because a localside started without `--project` takes its own ' +
3581
+ 'working directory as the root -- so a machine launched from its state directory can otherwise ' +
3582
+ 'run ONLY inside that directory, and a dispatch that asks for anywhere else fails as ' +
3583
+ '`PATH_INVALID` with nothing in the answer saying why. Turning this on does not widen what may ' +
3584
+ 'be RUN (the command allow-list and the executable path check still apply); it changes WHERE. ' +
3585
+ 'The resolved directory is reported back so a result says when it happened.',
3586
+ },
3574
3587
  timeout_ms: {
3575
3588
  type: 'number',
3576
3589
  // No `default` here on purpose. If the schema carried one, DSH would fill it in before
@@ -3967,6 +3980,8 @@ export async function apply(ctx, config = {}) {
3967
3980
  // the relay's normalization is the single source of that field.
3968
3981
  ...(argvUsable ? { command_argv: argv } : {}),
3969
3982
  cwd_rel: typeof args?.cwd_rel === 'string' && args.cwd_rel !== '' ? args.cwd_rel : '.',
3983
+ // Sent only when the caller asked, so every existing dispatch produces the same bytes on the wire.
3984
+ ...(args?.cwd_outside_project === true ? { cwd_outside_project: true } : {}),
3970
3985
  index_total: indexTotal,
3971
3986
  timeout_ms: timeoutMs,
3972
3987
  write,
@@ -607,6 +607,7 @@ export class RabbitState {
607
607
  mode: t.mode ?? 'replicate',
608
608
  command_argv: t.command_argv ?? [],
609
609
  cwd_rel: t.cwd_rel ?? '.',
610
+ cwd_outside_project: t.cwd_outside_project === true,
610
611
  index_total: t.index_total ?? 1,
611
612
  timeout_ms: t.timeout_ms ?? 300_000,
612
613
  write: t.write === true,
@@ -1475,6 +1476,10 @@ export class RabbitState {
1475
1476
  mode,
1476
1477
  command_argv: commandArgv,
1477
1478
  cwd_rel: cwdRel,
1479
+ // v0.43.0. The task construction is field-by-field, so a field not named here is silently dropped --
1480
+ // which is how a parameter can be accepted by the tool, validated by the tool, and never reach the
1481
+ // machine. Named explicitly so it travels.
1482
+ cwd_outside_project: input.cwd_outside_project === true,
1478
1483
  index_total: indexTotal,
1479
1484
  timeout_ms: timeoutMs,
1480
1485
  write: input.write === true,
@@ -1720,6 +1725,7 @@ export class RabbitState {
1720
1725
  mode: task.mode,
1721
1726
  command_argv: task.command_argv,
1722
1727
  cwd_rel: task.cwd_rel,
1728
+ cwd_outside_project: task.cwd_outside_project === true,
1723
1729
  index_total: task.index_total,
1724
1730
  timeout_ms: task.timeout_ms,
1725
1731
  write: task.write,
@@ -1848,6 +1854,9 @@ export class RabbitState {
1848
1854
  origin_machine_id: task.origin_machine_id ?? null,
1849
1855
  p2p: task.p2p ?? null,
1850
1856
  cwd_rel: task.cwd_rel,
1857
+ // Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
1858
+ // same reason `stages` above is conditional.
1859
+ ...(task.cwd_outside_project === true ? { cwd_outside_project: true } : {}),
1851
1860
  write: task.write,
1852
1861
  timeout_ms: task.timeout_ms,
1853
1862
  base_commit: task.base_commit,