@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +11 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +17 -0
- package/src/plugin/account-join.mjs +86 -0
- package/src/plugin/join-degradation.mjs +34 -0
- package/src/plugin/tools.mjs +57 -7
- package/src/relay/admission.mjs +250 -0
- package/src/relay/join-challenge.mjs +92 -0
- package/src/relay/server.mjs +1496 -1426
- package/src/relay/state.mjs +46 -0
- package/src/util/account-identity.mjs +75 -0
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.**
|
package/codex/mcp-servers.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.1.
|
|
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.
|
|
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": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
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
|
|