@twinsearth/w2m-dsh-plugin 2.1.10 → 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.
@@ -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.1.10"
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.1.10",
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.1.10"
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.1.10",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ 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
+
17
+ ## [2.1.11] - 2026-11-01
18
+
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.
20
+ - Tests: a stale-profile service yields the switched account's fleet key in test/chatops-account.test.mjs.
21
+
10
22
  ## [2.1.10] - 2026-11-01
11
23
 
12
24
  - Fleet binding: a DeepSeek account switch now re-pairs the machine instead of leaving it in the previous account's fleet. Startup compares the relay's fleet_id with the fleet id derived from the signed-in account, and a 60s watcher covers a switch without a process restart. A stored token is only valid for the account that minted it; it is no longer treated as proof of the current account.
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.1.10", "w2m-mcp"]
95
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.0", "w2m-mcp"]
96
96
  ```
97
97
 
98
98
  ### 5. 验证过的 release tarball(离线)
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.10", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.2.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "2.1.11",
3
+ "case": "ACCOUNT-SWITCH-PROFILE-21110",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "1bef9935-8c13-4dd9-9043-4e025320723f"
7
+ ],
8
+ "executed_at": "2026-10-31T19:11:34.238Z",
9
+ "executor": "lead",
10
+ "task_id": "account-switch-profile-21110-1793473894239",
11
+ "requirement": "After a DeepSeek account switch the plugin must refresh the cleared profile, derive the new fleet key and re-pair instead of keeping the previous fleet token",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "refresh": "identify() calls getProfile when getDeviceIdentity has no userId",
16
+ "key": "the switched account yields a different fleetKey",
17
+ "rebind": "the v2.1.10 fleet mismatch check then re-pairs"
18
+ }
19
+ }
20
+ }
@@ -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.1.10",
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.1.10",
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": [
@@ -300,6 +300,23 @@ export function createAccountIdentity(options = {}) {
300
300
  let identity;
301
301
  try {
302
302
  identity = await service.getDeviceIdentity();
303
+ /**
304
+ * A switched account is invisible until the profile is fetched.
305
+ *
306
+ * `getDeviceIdentity()` reads `getPlatformSession().userId`, and that is only populated
307
+ * from a profile already cached for the current grant. After credentials/record-updated
308
+ * the old profile is discarded and the session's userId is null until someone calls
309
+ * `getProfile()`. A machine that was paired before the switch therefore derived no new
310
+ * fleet key and kept its old token -- the reported "still in the previous account's
311
+ * fleet" state. Fetch the profile once when the identity is missing; the second read then
312
+ * sees the new account.
313
+ */
314
+ if ((identity?.userId == null) && typeof service.getProfile === 'function') {
315
+ try {
316
+ await service.getProfile();
317
+ identity = await service.getDeviceIdentity();
318
+ } catch { /* keep the signed-out answer if the profile cannot be read */ }
319
+ }
303
320
  } catch (error) {
304
321
  return { ok: false, reason: 'identity_read_failed', detail: error instanceof Error ? error.message : String(error) };
305
322
  }
@@ -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
  *
@@ -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.1.10';
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
- const device = await readDevice(cfg);
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: cfg.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
- if (configured !== null) return { fleetKey: configured, fleetKeySource: 'config', last: null };
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 <= attempts; attempt += 1) {
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 < attempts) {
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: null, fleetKeySource: null, last };
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 || fleetKeyFromConfig !== null) return;
8356
+ if (accountWatchBusy || cfg.fleetKeyPolicy === 'config') return;
8312
8357
  accountWatchBusy = true;
8313
8358
  try {
8314
8359
  const { createAccountIdentity } = await import('../chatops/account.mjs');