@twinsearth/w2m-dsh-plugin 1.1.3 → 1.1.15

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.1.3"
8
+ "version": "1.1.15"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.1.3",
15
+ "version": "1.1.15",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.1.3",
4
+ "version": "1.1.15",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,111 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## [1.1.15] — 2026-10-17
6
+
7
+ **A machine that cannot join the old way now proves its account, asks to join, and waits for an answer -- and the
8
+ executed command finally learns which machine and which slice it is.**
9
+
10
+ ### The fallback path is connected end to end
11
+
12
+ v1.1.14 shipped the server half. This release connects the client:
13
+
14
+ * \`pairNow\` classifies its failure and, for **only** the three degradable reasons (invalid pairing code, unreachable
15
+ relay, timeout), calls the new chain: challenge -> sign with the account key -> submit proof -> poll until a terminal
16
+ state. Anything else still needs a human and still does not change mechanisms;
17
+ * **a rejection is final.** The chain stops on \`rejected\`; re-asking would turn a decision into a notification queue;
18
+ * **an unfinished wait is reported, never hung on**: \`STILL_PENDING\` after the poll budget, \`expired\` when the relay
19
+ says so;
20
+ * the seed for the account key is the **fleet key, never the account code** -- the account code is written to the log,
21
+ and deriving a signing key from a value anyone can read would hand out the account's identity;
22
+ * the machine id comes from the agent object in scope, **not** from device.json: this machine never paired, which is the
23
+ entire situation.
24
+
25
+ ### Polling without a device token
26
+
27
+ The asking machine has no device token -- that is why it is asking. The request response therefore carries a
28
+ \`poll_token\`, and \`GET /v1/fleet/admission/{id}?poll_token=\` is reachable **ahead of** the device-token gate. A wrong
29
+ token returns 404 rather than revealing whether the request exists.
30
+
31
+ ### The command learns where it is (P1)
32
+
33
+ \`runArgv\` accepts an environment, and every executed command receives \`W2M_INDEX\`, \`W2M_INDEX_TOTAL\`,
34
+ \`W2M_STAGE_INDEX\`, \`W2M_TASK_ID\`, \`W2M_MODE\`, \`W2M_MACHINE_ID\`, \`W2M_MACHINE_NAME\`. Values come from the OFFER,
35
+ never from argv, so one machine cannot reshape a fleet-wide split. The variables are merged over \`process.env\` rather
36
+ than replacing it -- PATH must survive, or no interpreter is found. \`W2M_STAGE_INDEX\` is deliberately separate from
37
+ \`W2M_INDEX\`: for a split over four machines each runs one stage (0) but they are machines 0..3.
38
+
39
+ This makes the operator's acceptance shape possible: \`process.exit(Number(process.env.W2M_INDEX)+1)\` yields 1/2/3/4
40
+ across four machines. The single-machine half is tested here; the four-machine run needs the fleet and is **not yet
41
+ verified**.
42
+
43
+ ### NOT shipped, stated plainly
44
+
45
+ * **The approval UI** (10s popup with countdown plus a standing dialog) is not written. The server enforces the rules it
46
+ must: no automatic approval with zero members online, and a rejection beats the ten seconds;
47
+ * **no end-to-end run**: new install -> popup -> admitted has not been executed, so \`agent.runtime.machineId\` being
48
+ non-empty on a real machine is **UNVERIFIED** -- if it is empty the fallback logs and does not fire;
49
+ * the fleet chat / broadcast channel is designed (FLEET-CHAT-DESIGN.md) and not implemented.
50
+
51
+ ### Verified
52
+
53
+ Full gate GREEN (final pass, 15 of 15). Suites touched: tools 115, account-join 6, account-identity 6, join-challenge 8,
54
+ join-degradation 3, admission 16, admission-routes 3, relay 163, broadcast 19.
55
+
56
+ ## [1.1.14] — 2026-10-17
57
+
58
+ **A new machine joins by itself, and a machine that cannot join the old way can now prove its account and ask.**
59
+
60
+ ### Joining is on by default
61
+
62
+ `autoStartAgent` now defaults to **true**. The old expression was `config.autoStartAgent === true`, which made joining
63
+ opt-IN, and the observed defect was a machine that was installed, running, and simply not in the fleet -- "installed,
64
+ paired: false" with no hint of why. `false` is still the way to turn it off.
65
+
66
+ Making this the default required changing the meaning of the tests that intentionally ran without an agent: eight suites
67
+ now pin `autoStartAgent: false` through one `applyPinned` wrapper each, and the single case that asserts a STARTED node
68
+ exits cleanly passes `autoStartAgent: true` inside its own template script. Two bugs were found while doing it, and both
69
+ are recorded because they are the shape of this kind of change: inserting a key into object literals with a regex broke a
70
+ file, and replacing call sites after defining the wrapper made the wrapper call itself.
71
+
72
+ ### Admission: who may join, who may be removed
73
+
74
+ The relay gains a fleet-admission state machine and five routes: `admission/request`, `admission/pending`,
75
+ `admission/{id}/decide`, `members/{id}/remove`, `removals/{id}/vote`, plus `admission/challenge`.
76
+
77
+ * a request is `pending` and grants nothing until approved;
78
+ * **10 seconds without a decision auto-approves**, but only while at least one member is online -- an empty fleet cannot
79
+ consent, and that refusal is a named `NO_ONLINE_MEMBER`;
80
+ * inside **24 hours** any online member may remove a new member immediately; after 24 hours a single machine may only
81
+ OPEN a vote, and the quorum set is **frozen at open time** so a voter going offline mid-vote cannot move the bar;
82
+ * the quorum is the operator's: 3+ old members need >= 50% rounded up, a two-member fleet needs one vote;
83
+ * old members are ordered by **insertion sequence**, not timestamps -- two machines approved inside the same millisecond
84
+ are equal on the clock but still have an order, and a quorum that depends on a sub-millisecond race is not a quorum.
85
+
86
+ ### An unpaired machine can prove its account
87
+
88
+ A machine whose pairing failed has no device_token, which is the situation the fallback exists for -- so the two routes
89
+ it needs are reachable **without** one, authorised instead by an account signature:
90
+
91
+ * the account gains a **derived Ed25519 identity** (`account-identity.mjs`): the same account material yields the same
92
+ keypair on every machine, which is what makes zero-configuration joining possible at all;
93
+ * the public key is registered with the relay **when that account's first machine pairs** -- the only moment the relay has
94
+ proof of the account -- and the first key wins (a different key is refused, not silently accepted);
95
+ * a one-time, five-minute challenge is signed and verified against it; the challenge is consumed on any attempt, the
96
+ signature binds `machine_id`, and issuing is rate limited **per fingerprint**;
97
+ * the fingerprint the relay acts on comes from the **verified signature**, never from the request body.
98
+
99
+ ### Deliberately not in this release
100
+
101
+ The client-side consumption of `degrade: true` (challenge -> request -> poll) and the approval UI (10s popup plus a
102
+ standing dialog) are **not** shipped. The server half is tested; the client half is not written, and shipping a
103
+ mechanism whose client does not call it is the failure this project has recorded seven times.
104
+
105
+ ### Verified
106
+
107
+ Full gate: **GREEN**, 15 of 15 checks, twice. The suites this release touches directly: relay 163, tools 115, admission
108
+ 16, admission-routes 3, broadcast 19, join-challenge 8, account-identity 6, join-degradation 3, fleet-target 45.
109
+
5
110
  ## [1.1.3] — 2026-10-17
6
111
 
7
112
  **A machine offline for more than 72 hours leaves the list. It is not deleted, and it comes back on its own.**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.1.3", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.1.15", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.1.3",
3
+ "version": "1.1.15",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -129,10 +129,13 @@
129
129
  "pack": "node scripts/pack.mjs",
130
130
  "reachability": "node scripts/reachability-check.mjs",
131
131
  "reachability:classify": "node scripts/reachability-classify.mjs",
132
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.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/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
132
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.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/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs",
133
133
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
134
+ "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
135
+ "test:admission": "node --test --test-force-exit test/admission.test.mjs",
136
+ "test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
134
137
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
135
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.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/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
138
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.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/cwd-outside-project.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/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs",
136
139
  "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
137
140
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
138
141
  "test:authority": "node --test --test-force-exit test/authority.test.mjs",
@@ -155,6 +158,8 @@
155
158
  "test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
156
159
  "test:health": "node --test --test-force-exit test/health.test.mjs",
157
160
  "test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
161
+ "test:join-challenge": "node --test --test-force-exit test/join-challenge.test.mjs",
162
+ "test:join-degradation": "node --test --test-force-exit test/join-degradation.test.mjs",
158
163
  "test:layers": "node --test --test-force-exit test/layer-model.test.mjs",
159
164
  "test:log-bounds": "node --test --test-force-exit test/log-bounds.test.mjs",
160
165
  "test:memory": "node --test --test-force-exit test/memory.test.mjs",
@@ -168,7 +173,9 @@
168
173
  "test:test-report": "node --test --test-force-exit test/test-report.test.mjs",
169
174
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
170
175
  "verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
171
- "verify:join": "node scripts/verify-join.mjs"
176
+ "verify:join": "node scripts/verify-join.mjs",
177
+ "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
178
+ "test:account-join": "node --test --test-force-exit test/account-join.test.mjs"
172
179
  },
173
180
  "keywords": [
174
181
  "dsh-bundle",
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": "1.1.3",
4
+ "version": "1.1.15",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -3011,11 +3011,28 @@ export function createAgent(options) {
3011
3011
  break;
3012
3012
  }
3013
3013
 
3014
+ /**
3015
+ * v1.1.15 (P1): the executed command learns which machine and which slice it is.
3016
+ *
3017
+ * Values come from the OFFER, never from argv, so a fleet-wide split cannot be reshaped by one machine.
3018
+ * W2M_STAGE_INDEX is separate from W2M_INDEX: for a split over four machines, each runs one stage (0) but they
3019
+ * are machines 0..3, and conflating the two would make "print my index" print 0 everywhere.
3020
+ */
3021
+ const taskEnv = {
3022
+ W2M_INDEX: String(Number.isInteger(offer.index) ? offer.index : 0),
3023
+ W2M_INDEX_TOTAL: String(Number.isInteger(offer.index_total) ? offer.index_total : 1),
3024
+ W2M_STAGE_INDEX: String(Number.isInteger(stage.index) ? stage.index : 0),
3025
+ W2M_TASK_ID: String(offer.task_id ?? ''),
3026
+ W2M_MODE: String(offer.mode ?? ''),
3027
+ W2M_MACHINE_ID: String(offer.machine_id ?? ''),
3028
+ W2M_MACHINE_NAME: String(offer.machine_name ?? ''),
3029
+ };
3014
3030
  const ran = await runArgv(stage.command_argv, {
3015
3031
  cwd: stageCwdOutcome.cwd,
3016
3032
  timeoutMs: remainingMs,
3017
3033
  maxOutputBytes,
3018
3034
  signal: controller.signal,
3035
+ env: taskEnv,
3019
3036
  });
3020
3037
  execution = ran;
3021
3038
  for (const warning of ran.warnings) if (!warnings.includes(warning)) warnings.push(warning);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The fallback joining path, as the client actually walks it.
3
+ *
4
+ * ## Why this is a separate, injectable function
5
+ *
6
+ * The old path fails for three named reasons only (see join-degradation.mjs), and then this runs: ask for a challenge,
7
+ * sign it with the account key, submit the proof, wait for a human (or the ten-second popup) to decide. Every one of
8
+ * those steps can be slow or refuse, and the whole thing must be testable without a relay, a fleet, or a person.
9
+ *
10
+ * ## The two rules this encodes, both learned the hard way
11
+ *
12
+ * - **A rejection is final.** Re-asking after a "no" turns a decision into a notification queue and harasses the members
13
+ * who said no. Only a caller that explicitly resets may try again.
14
+ * - **Give up loudly.** If the request is never decided, the caller must be told which state it ended in (expired,
15
+ * pending past the deadline) rather than being left with a promise that never settles.
16
+ */
17
+ import { signChallenge } from '../util/account-identity.mjs';
18
+
19
+ /** Terminal states from the relay. \`pending\` is NOT terminal: it means "still waiting". */
20
+ const TERMINAL = new Set(['approved', 'rejected', 'expired']);
21
+
22
+ /**
23
+ * @param {{request: Function, identity: {fingerprint: string, privateKey: unknown},
24
+ * machine: {machine_id: string, machine_name?: string},
25
+ * basePath?: string, pollIntervalMs?: number, maxPolls?: number, sleep?: Function,
26
+ * now?: () => number}} deps
27
+ * @returns {Promise<{ok: boolean, state: string, device_token?: string, request_id?: string, code?: string}>}
28
+ */
29
+ export async function joinByAccount({
30
+ request,
31
+ identity,
32
+ machine,
33
+ basePath = '',
34
+ pollIntervalMs = 1000,
35
+ maxPolls = 30,
36
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
37
+ }) {
38
+ if (typeof request !== 'function') throw new TypeError('request must be a function');
39
+ if (!identity || typeof identity.fingerprint !== 'string') throw new TypeError('identity with a fingerprint is required');
40
+ if (!machine || typeof machine.machine_id !== 'string' || machine.machine_id === '') throw new TypeError('machine.machine_id is required');
41
+
42
+ const challenge = await request({
43
+ method: 'GET',
44
+ pathname: basePath + '/v1/fleet/admission/challenge',
45
+ query: { fingerprint: identity.fingerprint, machine_id: machine.machine_id },
46
+ });
47
+ if (challenge?.ok !== true) {
48
+ return { ok: false, state: 'error', code: challenge?.error?.code ?? 'CHALLENGE_REFUSED' };
49
+ }
50
+ const signature = signChallenge(identity, [challenge.json.challenge_id, challenge.json.nonce, identity.fingerprint, machine.machine_id].join('|'));
51
+
52
+ const submitted = await request({
53
+ method: 'POST',
54
+ pathname: basePath + '/v1/fleet/admission/request',
55
+ body: { machine_id: machine.machine_id, machine_name: machine.machine_name ?? null, challenge_id: challenge.json.challenge_id, signature },
56
+ });
57
+ if (submitted?.ok !== true) {
58
+ return { ok: false, state: 'error', code: submitted?.error?.code ?? 'REQUEST_REFUSED' };
59
+ }
60
+ let state = submitted.json.state ?? 'pending';
61
+ const requestId = submitted.json.request_id ?? null;
62
+ const pollToken = submitted.json.poll_token ?? null;
63
+
64
+ for (let attempt = 0; attempt < maxPolls && !TERMINAL.has(state); attempt += 1) {
65
+ if (attempt > 0) await sleep(pollIntervalMs);
66
+ const polled = await request({
67
+ method: 'GET',
68
+ pathname: basePath + '/v1/fleet/admission/' + encodeURIComponent(requestId ?? ''),
69
+ query: { poll_token: pollToken ?? '' },
70
+ });
71
+ if (polled?.ok !== true) {
72
+ return { ok: false, state: 'error', code: polled?.error?.code ?? 'POLL_REFUSED' };
73
+ }
74
+ state = polled.json.state ?? state;
75
+ if (TERMINAL.has(state)) {
76
+ return { ok: state === 'approved', state, request_id: requestId, device_token: polled.json.device_token };
77
+ }
78
+ }
79
+ // Not terminal within the budget: report the state we ended in rather than hanging.
80
+ return { ok: false, state, request_id: requestId, code: TERMINAL.has(state) ? undefined : 'STILL_PENDING' };
81
+ }
82
+
83
+ /** True only for a state a caller may treat as an answer. Exported so callers do not re-derive it. */
84
+ export function isTerminalAdmissionState(state) {
85
+ return TERMINAL.has(state);
86
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * When may the old joining path give up and the new one take over?
3
+ *
4
+ * The operator's rule (2026-10-17): "旧机制优先,失败才降级". The whole value of a fallback is that it fires when the
5
+ * old path **cannot** work -- and the whole danger is that it fires when the old path merely hiccuped. A machine pushed
6
+ * into an approval queue because the network blipped for two seconds is worse than one that retried.
7
+ *
8
+ * So this is a whitelist of three reasons, and everything else is "retry the old path":
9
+ *
10
+ * 1. the pairing code is invalid or already used -- the old path can never succeed with it;
11
+ * 2. the relay is unreachable at the configured address -- no code will help;
12
+ * 3. pairing timed out -- the old path got its chance and did not finish.
13
+ *
14
+ * Everything else (401 on the operator token, a config error, a permission refusal, an unexpected 5xx) is a signal that
15
+ * something needs a human, **not** a signal to change mechanisms. Defaulting to `false` is deliberate: an unknown
16
+ * failure must not silently move a machine between flows.
17
+ */
18
+
19
+ /** The only three reasons the old path may be abandoned. */
20
+ export const DEGRADABLE_REASONS = Object.freeze([
21
+ 'PAIRING_CODE_INVALID',
22
+ 'RELAY_UNREACHABLE',
23
+ 'PAIRING_TIMEOUT',
24
+ ]);
25
+
26
+ /**
27
+ * @param {string|{reason_code?: string, code?: string, status?: number}} failure
28
+ * @returns {boolean} true only for the three whitelisted reasons.
29
+ */
30
+ export function shouldDegrade(failure) {
31
+ const reason = typeof failure === 'string' ? failure : (failure?.reason_code ?? failure?.code ?? null);
32
+ if (typeof reason !== 'string') return false;
33
+ return DEGRADABLE_REASONS.includes(reason);
34
+ }
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * DSH: Windows2MacO - the DSH Cordis plugin.
3
3
  *
4
4
  * # What this exposes
@@ -107,6 +107,9 @@ import { acceptWithAuthority } from '../agent/field-authority.mjs';
107
107
  // v1.0.11: the merge rule (#2b) lives in its own module so the operator's two layers can be tested as a rule,
108
108
  // exhaustively, without a profile or a relay.
109
109
  import { mergeMemory } from './fleet-merge.mjs';
110
+ import { shouldDegrade } from './join-degradation.mjs';
111
+ import { deriveAccountKeypair } from '../util/account-identity.mjs';
112
+ import { joinByAccount } from './account-join.mjs';
110
113
 
111
114
  // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
112
115
  // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
@@ -211,7 +214,7 @@ const MAX_STAGES = 16;
211
214
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
212
215
  * overrides it at runtime for tests and packagers.
213
216
  */
214
- const PLUGIN_VERSION = '1.1.3';
217
+ const PLUGIN_VERSION = '1.1.15';
215
218
 
216
219
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
217
220
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -687,7 +690,8 @@ function readConfig(config = {}) {
687
690
  machineName: typeof config.machineName === 'string' && config.machineName.trim() !== ''
688
691
  ? config.machineName.trim()
689
692
  : os.hostname(),
690
- autoStartAgent: config.autoStartAgent === true,
693
+ // v1.1.6: joining is on by default; `false` is the only way off.
694
+ autoStartAgent: config.autoStartAgent !== false,
691
695
  /**
692
696
  * v0.8.3: the account-derived key this machine joins its fleet with.
693
697
  *
@@ -6704,10 +6708,56 @@ async function startLocalStack(ctx, cfg) {
6704
6708
  ctx.logger?.info?.(`w2m: paired by ${by}`);
6705
6709
  return { paired: true, by };
6706
6710
  } catch (error) {
6707
- // Named rather than swallowed: an agent that failed to pair runs and does nothing, which
6708
- // looks identical to a fleet with no work.
6709
- ctx.logger?.warn?.(`w2m: pairing by ${by} failed: ${error instanceof Error ? error.message : String(error)}`);
6710
- return { paired: false, by };
6711
+ /**
6712
+ * The failure is classified here, where the reason still exists.
6713
+ *
6714
+ * v1.1.6 adds a second joining path (account fingerprint + member approval) that may only take over when the
6715
+ * FIRST path cannot work -- an invalid pairing code, an unreachable relay, a timeout. Anything else (a config
6716
+ * error, a permission refusal) needs a human, and moving the machine between mechanisms would hide that. The
6717
+ * classifier owns that rule; this only has to stop discarding the error before it can be read.
6718
+ */
6719
+ const reason = error?.reason_code ?? error?.code ?? null;
6720
+ const degrade = shouldDegrade(reason);
6721
+ if (degrade === true && key !== null) {
6722
+ /**
6723
+ * v1.1.15: the fallback path, finally called.
6724
+ *
6725
+ * Two invariants are load-bearing here:
6726
+ *
6727
+ * 1. the seed is the FLEET KEY, never the account code -- the account code is written to the log two
6728
+ * hundred lines above, and deriving a signing key from a value anyone can read would hand out the
6729
+ * account's identity;
6730
+ * 2. the machine id comes from the agent object that is already in this scope, not from device.json --
6731
+ * this machine never paired, so it has no device.json, which is the entire situation.
6732
+ *
6733
+ * A failure here does not change the outcome of the old attempt: the caller still sees
6734
+ * `{paired: false, degrade: true}` and can retry later. It must not throw out of this catch block.
6735
+ */
6736
+ try {
6737
+ const localMachineId = typeof agent?.runtime?.machineId === 'string' ? agent.runtime.machineId
6738
+ : (typeof agent?.machineId === 'string' ? agent.machineId : '');
6739
+ if (localMachineId !== '') {
6740
+ const joined = await joinByAccount({
6741
+ request,
6742
+ identity: deriveAccountKeypair(key),
6743
+ machine: { machine_id: localMachineId, machine_name: cfg.machineName ?? null },
6744
+ maxPolls: 10,
6745
+ });
6746
+ if (joined.ok === true && typeof joined.device_token === 'string') {
6747
+ saveDeviceToken(joined.device_token, identityOptions);
6748
+ ctx.logger?.info?.('w2m: joined by account admission (' + joined.state + ')');
6749
+ return { paired: true, by: 'account-admission' };
6750
+ }
6751
+ ctx.logger?.warn?.('w2m: account admission did not admit this machine (' + joined.state + (joined.code ? '/' + joined.code : '') + ')');
6752
+ } else {
6753
+ ctx.logger?.warn?.('w2m: no local machine id, so the account-admission fallback cannot ask to join');
6754
+ }
6755
+ } catch (fallbackError) {
6756
+ ctx.logger?.warn?.('w2m: account-admission fallback failed: ' + (fallbackError instanceof Error ? fallbackError.message : String(fallbackError)));
6757
+ }
6758
+ }
6759
+ ctx.logger?.warn?.(`w2m: pairing by ${by} failed${reason === null ? '' : ' (' + String(reason) + ')'}: ${error instanceof Error ? error.message : String(error)}`);
6760
+ return { paired: false, by, reason_code: reason, degrade };
6711
6761
  }
6712
6762
  }
6713
6763