@twinsearth/w2m-dsh-plugin 2.1.11 → 2.2.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.2.0/ACCOUNT-SWITCH-POLICY-2200.json +21 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/fleet-binding.mjs +25 -0
- package/src/plugin/tools.mjs +54 -9
|
@@ -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": "2.
|
|
8
|
+
"version": "2.2.0"
|
|
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": "2.
|
|
15
|
+
"version": "2.2.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.
|
|
29
|
+
"version": "2.2.0"
|
|
30
30
|
}
|
|
@@ -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": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.2.0] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
- Fleet binding policy: `fleetKeyPolicy: account` is the new default. A configured `fleetKey` is a fallback while the signed-in account is authoritative, so switching DeepSeek accounts moves the machine without editing every profile. Set `fleetKeyPolicy: config` to keep the old explicit-pin behaviour.
|
|
13
|
+
- `w2m_status` now reads `device.json` through the `DSH_HOME/xclient` fallback when `stateDir` is unset; it no longer reports `paired: false` / `FLEET_UNPAIRED` on a working machine. The reported fleet is compared against the key the stack actually resolved.
|
|
14
|
+
- Includes the v2.1.10 `spawnSync.status` capture fix and the v2.1.11 profile-refresh fix for a switched account.
|
|
15
|
+
- Breaking: an explicit `fleetKey` no longer pins the fleet by default; add `fleetKeyPolicy: config` to opt out.
|
|
16
|
+
|
|
10
17
|
## [2.1.11] - 2026-11-01
|
|
11
18
|
|
|
12
19
|
- Account switch: refresh the account profile when `getDeviceIdentity()` has no userId. After a DeepSeek account switch the host clears its cached profile, so the identity read returns no userId until someone calls `getProfile()`; the plugin then derived no new fleet key and kept the previous account's token. It now fetches the profile once, derives the new key, detects the fleet mismatch and re-pairs. This is the remaining half of the v2.1.10 account-switch fix.
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ w2m-mcp --help
|
|
|
92
92
|
# 复制进你自己的 codex/mcp-servers.toml
|
|
93
93
|
[mcp_servers.w2m]
|
|
94
94
|
command = "npx"
|
|
95
|
-
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.
|
|
95
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.0", "w2m-mcp"]
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.2.0",
|
|
3
|
+
"case": "ACCOUNT-SWITCH-POLICY-2200",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"1bef9935-8c13-4dd9-9043-4e025320723f"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T20:06:14.655Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "account-switch-policy-2200-1793477174656",
|
|
11
|
+
"requirement": "A DeepSeek account switch must move a machine without per-machine fleetKey edits, while w2m_status must use the same device identity fallback as the other tools",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"policy": "fleetKeyPolicy defaults account; configured key is fallback; config opts out",
|
|
16
|
+
"status": "w2m_status reads DSH_HOME/xclient device.json when stateDir is unset",
|
|
17
|
+
"select": "selectFleetKey account/config/no-account tests pass",
|
|
18
|
+
"account": "profile refresh and fleet mismatch re-pair from 2.1.11"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
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
|
"exports": {
|
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": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 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": [
|
|
@@ -39,6 +39,31 @@ export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null
|
|
|
39
39
|
return typeof expectedFleetId === 'string' && expectedFleetId !== '' && fleetId !== expectedFleetId;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Choose the key a start actually binds with.
|
|
44
|
+
*
|
|
45
|
+
* `fleetKeyPolicy: 'account'` (the default) makes the signed-in account authoritative: an explicit
|
|
46
|
+
* `fleetKey` is a **fallback** for when the account cannot be read, not a pin that survives an
|
|
47
|
+
* account switch. `'config'` restores the old operator-pin behaviour for fleets that deliberately
|
|
48
|
+
* outlive the signed-in account.
|
|
49
|
+
*
|
|
50
|
+
* The distinction exists because a key in the profile has no provenance: it may have been placed
|
|
51
|
+
* there by an earlier setup for the *previous* account, or by an operator who means to pin the fleet.
|
|
52
|
+
* The policy is the machine-readable statement of which one it is; without it, auto-switching would
|
|
53
|
+
* silently override a deliberate pin.
|
|
54
|
+
*
|
|
55
|
+
* @param {{configured?: string|null, account?: string|null, policy?: string}} input
|
|
56
|
+
* @returns {{fleetKey: string|null, source: 'account'|'config'|null}}
|
|
57
|
+
*/
|
|
58
|
+
export function selectFleetKey({ configured = null, account = null, policy = 'account' } = {}) {
|
|
59
|
+
const hasConfig = typeof configured === 'string' && configured.trim() !== '';
|
|
60
|
+
const hasAccount = typeof account === 'string' && account.trim() !== '';
|
|
61
|
+
if (policy === 'config' && hasConfig) return { fleetKey: configured, source: 'config' };
|
|
62
|
+
if (hasAccount) return { fleetKey: account, source: 'account' };
|
|
63
|
+
if (hasConfig) return { fleetKey: configured, source: 'config' };
|
|
64
|
+
return { fleetKey: null, source: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
/**
|
|
43
68
|
* Read this machine's relay record and classify its fleet binding.
|
|
44
69
|
*
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
|
|
2
|
-
import { diagnoseFleetBinding, shouldRejoin } from './fleet-binding.mjs';
|
|
2
|
+
import { diagnoseFleetBinding, shouldRejoin, selectFleetKey } from './fleet-binding.mjs';
|
|
3
3
|
import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
|
|
4
4
|
/**
|
|
5
5
|
* DSH: Windows2MacO - the DSH Cordis plugin.
|
|
@@ -293,7 +293,7 @@ const MAX_STAGES = 16;
|
|
|
293
293
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
294
294
|
* overrides it at runtime for tests and packagers.
|
|
295
295
|
*/
|
|
296
|
-
const PLUGIN_VERSION = '2.
|
|
296
|
+
const PLUGIN_VERSION = '2.2.0';
|
|
297
297
|
|
|
298
298
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
299
299
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -799,6 +799,17 @@ function readConfig(config = {}) {
|
|
|
799
799
|
: (typeof process.env.W2M_FLEET_KEY === 'string' && process.env.W2M_FLEET_KEY.trim() !== ''
|
|
800
800
|
? process.env.W2M_FLEET_KEY.trim()
|
|
801
801
|
: null),
|
|
802
|
+
/**
|
|
803
|
+
* v2.2.0: which key wins when a configured key and the signed-in account disagree.
|
|
804
|
+
*
|
|
805
|
+
* `'account'` (default) follows the account and treats the configured key as a fallback, so an
|
|
806
|
+
* account switch moves the machine without editing every profile. `'config'` is the explicit
|
|
807
|
+
* operator pin and keeps the old behaviour. The old silent "config always wins" made an account
|
|
808
|
+
* switch look like a binding bug on every machine that had ever stored a key.
|
|
809
|
+
*/
|
|
810
|
+
fleetKeyPolicy: (config.fleetKeyPolicy === 'config' || process.env.W2M_FLEET_KEY_POLICY === 'config')
|
|
811
|
+
? 'config'
|
|
812
|
+
: 'account',
|
|
802
813
|
// From the merged layers, not from `config` alone: this is the value the fleet shares.
|
|
803
814
|
allowedCommands: shared.values.allowedCommands
|
|
804
815
|
.filter((entry) => typeof entry === 'string')
|
|
@@ -4904,7 +4915,18 @@ const result = await request({
|
|
|
4904
4915
|
// Read, not required: this tool exists to describe a half-configured machine, so an absent or
|
|
4905
4916
|
// unpaired device.json is part of the answer rather than a reason to refuse. `/healthz` needs
|
|
4906
4917
|
// no bearer token, so nothing here depends on the token being present.
|
|
4907
|
-
|
|
4918
|
+
/**
|
|
4919
|
+
* v2.2.0: use the same identity path every other tool uses.
|
|
4920
|
+
*
|
|
4921
|
+
* `readDevice(cfg)` returns an empty identity when `stateDir` is unset, while dispatch and
|
|
4922
|
+
* history fall back to `DSH_HOME/xclient/device.json`. `w2m_status` was the only reader that
|
|
4923
|
+
* skipped that fallback, so a working machine reported `paired: false` / `FLEET_UNPAIRED`
|
|
4924
|
+
* and sent the reader to fix a pairing that was already fine.
|
|
4925
|
+
*/
|
|
4926
|
+
const statusDeviceFile = devicePathFor(cfg);
|
|
4927
|
+
const device = statusDeviceFile === null
|
|
4928
|
+
? await readDevice(cfg)
|
|
4929
|
+
: await readDevice({ stateDir: path.dirname(statusDeviceFile) });
|
|
4908
4930
|
|
|
4909
4931
|
// A cheap reachability probe of a documented endpoint. Without a configured URL there is
|
|
4910
4932
|
// nothing to probe, and that is reported rather than guessed at.
|
|
@@ -5038,7 +5060,7 @@ const result = await request({
|
|
|
5038
5060
|
relayUrl: cfg.rabbitUrl,
|
|
5039
5061
|
token: device.device_token,
|
|
5040
5062
|
machineId: device.machine_id,
|
|
5041
|
-
fleetKey:
|
|
5063
|
+
fleetKey: statusFleetKey,
|
|
5042
5064
|
expectedFleetId,
|
|
5043
5065
|
});
|
|
5044
5066
|
|
|
@@ -5078,6 +5100,7 @@ const result = await request({
|
|
|
5078
5100
|
allowedCommands: cfg.allowedCommands,
|
|
5079
5101
|
pairingCode_configured: cfg.pairingCode !== null,
|
|
5080
5102
|
fleetKey_configured: cfg.fleetKey !== null,
|
|
5103
|
+
fleetKey_policy: cfg.fleetKeyPolicy,
|
|
5081
5104
|
p2pMode: cfg.p2pMode,
|
|
5082
5105
|
// The resolved list, shared server first, exactly as the node will query it.
|
|
5083
5106
|
stunServers: cfg.stunServers,
|
|
@@ -7962,10 +7985,24 @@ async function startLocalStack(ctx, cfg) {
|
|
|
7962
7985
|
*/
|
|
7963
7986
|
async function deriveFleetKeyOnce(attempts = FAST_DERIVE_ATTEMPTS) {
|
|
7964
7987
|
const configured = fleetKeyFromConfig;
|
|
7965
|
-
|
|
7988
|
+
/**
|
|
7989
|
+
* v2.2.0: the configured key is a **fallback** unless the operator pins it.
|
|
7990
|
+
*
|
|
7991
|
+
* Before this, a key in the profile returned immediately and the account was never read.
|
|
7992
|
+
* That is why a machine with an old key stayed in the previous account's fleet even after
|
|
7993
|
+
* v2.1.10/v2.1.11 taught the binding code to compare and re-pair: it never got a new key to
|
|
7994
|
+
* compare with. The default policy now reads the account first; `fleetKeyPolicy: 'config'`
|
|
7995
|
+
* restores the explicit pin for fleets that outlive the signed-in account.
|
|
7996
|
+
*/
|
|
7997
|
+
if (cfg.fleetKeyPolicy === 'config' && configured !== null) {
|
|
7998
|
+
return { fleetKey: configured, fleetKeySource: 'config', last: null };
|
|
7999
|
+
}
|
|
7966
8000
|
|
|
8001
|
+
// A configured key is a usable fallback, so do not spend the full retry budget before
|
|
8002
|
+
// using it. One account read is enough at mount; the watcher retries if the profile is cold.
|
|
8003
|
+
const effectiveAttempts = configured !== null ? 1 : attempts;
|
|
7967
8004
|
let last = null;
|
|
7968
|
-
for (let attempt = 1; attempt <=
|
|
8005
|
+
for (let attempt = 1; attempt <= effectiveAttempts; attempt += 1) {
|
|
7969
8006
|
try {
|
|
7970
8007
|
const { createAccountIdentity } = await import('../chatops/account.mjs');
|
|
7971
8008
|
last = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
|
|
@@ -7982,20 +8019,28 @@ async function startLocalStack(ctx, cfg) {
|
|
|
7982
8019
|
*/
|
|
7983
8020
|
const derived = last && typeof last.fleetKey === 'string' && last.fleetKey !== '' ? last.fleetKey : null;
|
|
7984
8021
|
if (derived !== null) {
|
|
8022
|
+
if (configured !== null && configured !== derived) {
|
|
8023
|
+
ctx.logger?.warn?.('w2m: the signed-in account derives a different fleet than the configured fleetKey; following the account (set fleetKeyPolicy: config to pin the configured key)');
|
|
8024
|
+
}
|
|
7985
8025
|
ctx.logger?.info?.(
|
|
7986
8026
|
`w2m: fleet key derived from the signed-in account on attempt ${attempt} (fleet ${last.accountCode})`
|
|
7987
8027
|
+ (last.ok === true ? '' : `; no serial number: ${last.reason ?? 'unknown reason'}`),
|
|
7988
8028
|
);
|
|
7989
8029
|
return { fleetKey: derived, fleetKeySource: 'account', last };
|
|
7990
8030
|
}
|
|
7991
|
-
if (attempt <
|
|
8031
|
+
if (attempt < effectiveAttempts) {
|
|
7992
8032
|
const waitMs = Math.min(2000 * 2 ** (attempt - 1), 30000);
|
|
7993
8033
|
ctx.logger?.info?.(`w2m: no fleet key yet (${describeIdentityFailure(last)}); retrying in ${waitMs}ms`);
|
|
7994
8034
|
await new Promise((resolve) => { setTimeout(resolve, waitMs); });
|
|
7995
8035
|
}
|
|
7996
8036
|
}
|
|
8037
|
+
// Account unavailable: the configured key, if any, is the documented fallback.
|
|
8038
|
+
const fallback = selectFleetKey({ configured, account: null, policy: cfg.fleetKeyPolicy });
|
|
8039
|
+
if (fallback.fleetKey !== null) {
|
|
8040
|
+
ctx.logger?.warn?.('w2m: the signed-in account produced no fleet key; falling back to the configured fleetKey (mark it fleetKeyPolicy: config to make that choice explicit)');
|
|
8041
|
+
}
|
|
7997
8042
|
// Not fatal: the caller may pair by code, and a later attempt may still succeed.
|
|
7998
|
-
return { fleetKey:
|
|
8043
|
+
return { fleetKey: fallback.fleetKey, fleetKeySource: fallback.source, last };
|
|
7999
8044
|
}
|
|
8000
8045
|
|
|
8001
8046
|
let { fleetKey, fleetKeySource } = await deriveFleetKeyOnce();
|
|
@@ -8308,7 +8353,7 @@ async function startLocalStack(ctx, cfg) {
|
|
|
8308
8353
|
let accountWatch = null;
|
|
8309
8354
|
let accountWatchBusy = false;
|
|
8310
8355
|
async function recheckAccountBinding() {
|
|
8311
|
-
if (accountWatchBusy ||
|
|
8356
|
+
if (accountWatchBusy || cfg.fleetKeyPolicy === 'config') return;
|
|
8312
8357
|
accountWatchBusy = true;
|
|
8313
8358
|
try {
|
|
8314
8359
|
const { createAccountIdentity } = await import('../chatops/account.mjs');
|