@twinsearth/w2m-dsh-plugin 2.1.11 → 2.2.1

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.
@@ -1,5 +1,7 @@
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
+ import { fleetHomePath, getFleetHome, readFleetHome, rememberFleetAccount, rememberFleetHome, shouldPreserveExistingFleet } from './fleet-home.mjs';
4
+ import { fleetConfigPathFor, readFleetConfig } from './fleet-config.mjs';
3
5
  import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
4
6
  /**
5
7
  * DSH: Windows2MacO - the DSH Cordis plugin.
@@ -194,6 +196,7 @@ import { registerChatRoutes } from './fleet-chat-route.mjs';
194
196
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
195
197
  import { registerPluginUpdateRoutes } from './update-route.mjs';
196
198
  import { registerOperatorTokenRoute } from './operator-token-route.mjs';
199
+ import { registerFleetConfigRoute } from './fleet-config-route.mjs';
197
200
  import { registerDataplaneSinkRoute } from './dataplane-sink-route.mjs';
198
201
  import { registerProjectStreamRoute } from './project-stream-route.mjs';
199
202
  import { planProgress } from './joint-dev/progress.mjs';
@@ -293,7 +296,7 @@ const MAX_STAGES = 16;
293
296
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
294
297
  * overrides it at runtime for tests and packagers.
295
298
  */
296
- const PLUGIN_VERSION = '2.1.11';
299
+ const PLUGIN_VERSION = '2.2.1';
297
300
 
298
301
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
299
302
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -453,6 +456,28 @@ function configError(setting, why, hint, reasonCode) {
453
456
  });
454
457
  }
455
458
 
459
+ /**
460
+ * Read `fleetKeyPolicy` without silently accepting a typo.
461
+ *
462
+ * The three values are materially different: `home` returns to the machine's previous fleet for
463
+ * this account, `account` follows the signed-in account and may create/join a new fleet, and
464
+ * `config` pins one operator-supplied key. A typo that defaulted to any one of them would be a
465
+ * silent fleet move, so it is a startup error naming the setting.
466
+ */
467
+ function resolveFleetKeyPolicy(config, env) {
468
+ const fromConfig = typeof config?.fleetKeyPolicy === 'string' ? config.fleetKeyPolicy.trim() : '';
469
+ const fromEnv = typeof env?.W2M_FLEET_KEY_POLICY === 'string' ? env.W2M_FLEET_KEY_POLICY.trim() : '';
470
+ const value = fromConfig !== '' ? fromConfig : (fromEnv !== '' ? fromEnv : 'home');
471
+ if (value === 'home' || value === 'account' || value === 'config') return value;
472
+ const setting = fromConfig !== '' ? 'fleetKeyPolicy' : 'W2M_FLEET_KEY_POLICY';
473
+ throw configError(
474
+ setting,
475
+ `must be one of "home", "account" or "config" (got "${value}")`,
476
+ 'set fleetKeyPolicy to home (return to the previous fleet for this account), account (follow the signed-in account) or config (pin the configured key)',
477
+ );
478
+ }
479
+
480
+
456
481
  // ---------------------------------------------------------------------------------------------
457
482
  // Cooperative cancellation
458
483
  // ---------------------------------------------------------------------------------------------
@@ -738,6 +763,22 @@ function readConfig(config = {}) {
738
763
  const stateDir = typeof config.stateDir === 'string' && config.stateDir.trim() !== ''
739
764
  ? path.resolve(config.stateDir.trim())
740
765
  : null;
766
+ /**
767
+ * The settings-page pin. This is the supported alternative to editing `cordis.patch.yml`: the
768
+ * route writes it atomically and the plugin reads it here with higher precedence than the profile
769
+ * fields. A corrupt pin fails startup with the path named rather than silently using the account
770
+ * key and moving the machine.
771
+ */
772
+ const fleetConfigFile = fleetConfigPathFor(config.stateDir);
773
+ const fleetConfigRead = fleetConfigFile === null ? { ok: true, config: null } : readFleetConfig(fleetConfigFile);
774
+ if (fleetConfigRead.ok !== true) {
775
+ throw configError(
776
+ 'fleet-config.json',
777
+ `is unreadable (${fleetConfigRead.code}: ${fleetConfigRead.error})`,
778
+ `repair or remove ${fleetConfigRead.path ?? fleetConfigFile ?? 'fleet-config.json'}; the settings page writes it atomically`,
779
+ );
780
+ }
781
+ const fleetPin = fleetConfigRead.config;
741
782
 
742
783
  // §7: both of these are validated here, at `apply()` time, so a typo is a startup error that names
743
784
  // the setting rather than a status field nobody reads. See the two resolvers for why there is no
@@ -794,11 +835,21 @@ function readConfig(config = {}) {
794
835
  * computed where it is used. `W2M_FLEET_KEY` is accepted too, so a machine can be pointed at
795
836
  * a fleet without editing a profile.
796
837
  */
797
- fleetKey: typeof config.fleetKey === 'string' && config.fleetKey.trim() !== ''
838
+ fleetKey: fleetPin?.fleetKey ?? (typeof config.fleetKey === 'string' && config.fleetKey.trim() !== ''
798
839
  ? config.fleetKey.trim()
799
840
  : (typeof process.env.W2M_FLEET_KEY === 'string' && process.env.W2M_FLEET_KEY.trim() !== ''
800
841
  ? process.env.W2M_FLEET_KEY.trim()
801
- : null),
842
+ : null)),
843
+ /**
844
+ * v2.2.1: which key wins when a configured key, the account-derived key and this machine's
845
+ * previous fleet for that account disagree.
846
+ *
847
+ * `home` (default) returns to the last fleet used for the signed-in account; `account` is the
848
+ * v2.2.0 opt-in that always follows the account; `config` is the explicit operator pin. A pin
849
+ * written from the settings page is authoritative and carries its own policy.
850
+ */
851
+ fleetKeyPolicy: fleetPin?.policy ?? resolveFleetKeyPolicy(config, process.env),
852
+ fleetConfigFile,
802
853
  // From the merged layers, not from `config` alone: this is the value the fleet shares.
803
854
  allowedCommands: shared.values.allowedCommands
804
855
  .filter((entry) => typeof entry === 'string')
@@ -3610,6 +3661,8 @@ export async function apply(ctx, config = {}) {
3610
3661
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3611
3662
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3612
3663
  const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
3664
+ // v2.2.1: the settings page writes an independent fleet pin. It never edits cordis.patch.yml.
3665
+ const _fleetConfigRoute = registerFleetConfigRoute(ctx, { file: () => fleetConfigPathFor(cfg.stateDir) });
3613
3666
  // A3/S4: local-user authorization of the artifact sink. Not reachable from any tool schema.
3614
3667
  const _dataplaneSinkRoute = registerDataplaneSinkRoute(ctx, { stateDir: () => resolveStateDir(cfg.stateDir, 'joint-dev') });
3615
3668
  // A6: read-only project SSE proxy (device token stays in the host process).
@@ -4904,7 +4957,18 @@ const result = await request({
4904
4957
  // Read, not required: this tool exists to describe a half-configured machine, so an absent or
4905
4958
  // unpaired device.json is part of the answer rather than a reason to refuse. `/healthz` needs
4906
4959
  // no bearer token, so nothing here depends on the token being present.
4907
- const device = await readDevice(cfg);
4960
+ /**
4961
+ * v2.2.0: use the same identity path every other tool uses.
4962
+ *
4963
+ * `readDevice(cfg)` returns an empty identity when `stateDir` is unset, while dispatch and
4964
+ * history fall back to `DSH_HOME/xclient/device.json`. `w2m_status` was the only reader that
4965
+ * skipped that fallback, so a working machine reported `paired: false` / `FLEET_UNPAIRED`
4966
+ * and sent the reader to fix a pairing that was already fine.
4967
+ */
4968
+ const statusDeviceFile = devicePathFor(cfg);
4969
+ const device = statusDeviceFile === null
4970
+ ? await readDevice(cfg)
4971
+ : await readDevice({ stateDir: path.dirname(statusDeviceFile) });
4908
4972
 
4909
4973
  // A cheap reachability probe of a documented endpoint. Without a configured URL there is
4910
4974
  // nothing to probe, and that is reported rather than guessed at.
@@ -5038,7 +5102,7 @@ const result = await request({
5038
5102
  relayUrl: cfg.rabbitUrl,
5039
5103
  token: device.device_token,
5040
5104
  machineId: device.machine_id,
5041
- fleetKey: cfg.fleetKey,
5105
+ fleetKey: statusFleetKey,
5042
5106
  expectedFleetId,
5043
5107
  });
5044
5108
 
@@ -5066,7 +5130,12 @@ const result = await request({
5066
5130
  rabbit_source: cfg.rabbitSource,
5067
5131
  rabbit_url: cfg.rabbitUrl,
5068
5132
  p2p: p2pStatus,
5069
- fleet: fleetBinding,
5133
+ fleet: {
5134
+ ...fleetBinding,
5135
+ key_source: resolvedFleetKey.source
5136
+ ?? (cfg.fleetKey !== null && cfg.fleetKeyPolicy === 'config' ? 'config' : null),
5137
+ home_file: resolvedFleetKey.homeFile ?? null,
5138
+ },
5070
5139
  rtt,
5071
5140
  config: {
5072
5141
  rabbitUrl: cfg.rabbitUrl,
@@ -5078,6 +5147,9 @@ const result = await request({
5078
5147
  allowedCommands: cfg.allowedCommands,
5079
5148
  pairingCode_configured: cfg.pairingCode !== null,
5080
5149
  fleetKey_configured: cfg.fleetKey !== null,
5150
+ fleetKey_policy: cfg.fleetKeyPolicy,
5151
+ fleet_home_file: resolvedFleetKey.homeFile ?? null,
5152
+ fleet_config_file: cfg.fleetConfigFile ?? null,
5081
5153
  p2pMode: cfg.p2pMode,
5082
5154
  // The resolved list, shared server first, exactly as the node will query it.
5083
5155
  stunServers: cfg.stunServers,
@@ -7596,7 +7668,7 @@ function sleepWithSignal(ms, signal) {
7596
7668
  * a machine that joined by account -- and the panel then refused every request with "no fleet-chat identity yet".
7597
7669
  * A holder rather than a parameter because the route is mounted before the stack starts, and reads lazily.
7598
7670
  */
7599
- const resolvedFleetKey = { value: null, source: null, at: 0 };
7671
+ const resolvedFleetKey = { value: null, source: null, at: 0, homeFile: null, accountCode: null };
7600
7672
 
7601
7673
  /** Probe one relay /healthz; any failure is `false` (a timeout must not throw into the interval). */
7602
7674
  async function probeRelayHealth(url, timeoutMs) {
@@ -7696,6 +7768,16 @@ function agentIdentityOf(agent) {
7696
7768
  }
7697
7769
  }
7698
7770
  async function startLocalStack(ctx, cfg) {
7771
+ /**
7772
+ * The per-account history of this machine's fleets. It lives beside `device.json`, which is the
7773
+ * one state directory the plugin already resolves consistently. Without a directory there is no
7774
+ * safe place to remember a home, so `home` policy degrades to the account key and says so.
7775
+ */
7776
+ const fleetHomeFile = fleetHomePath(devicePathFor(cfg));
7777
+ resolvedFleetKey.homeFile = fleetHomeFile;
7778
+ if (fleetHomeFile === null && cfg.fleetKeyPolicy === 'home') {
7779
+ ctx.logger?.warn?.('w2m: no state directory is known, so fleet homes cannot be recorded; falling back to the account key');
7780
+ }
7699
7781
 
7700
7782
  /**
7701
7783
  * v1.5.2: bootstrap relay assignment. A fresh install has no relay list; without this it falls
@@ -7928,6 +8010,37 @@ async function startLocalStack(ctx, cfg) {
7928
8010
  */
7929
8011
  const fleetKeyFromConfig = cfg.fleetKey ?? null;
7930
8012
 
8013
+ /**
8014
+ * The home for one account, or the reason it cannot be read.
8015
+ *
8016
+ * A missing file is an empty history. A corrupt file is **not**: under `home` policy it must
8017
+ * stop the derivation rather than silently mint a new account fleet.
8018
+ */
8019
+ const homeFor = (accountCode) => {
8020
+ if (fleetHomeFile === null) return { ok: true, entry: null, lastAccount: null };
8021
+ const read = readFleetHome(fleetHomeFile);
8022
+ if (read.ok !== true) return { ok: false, error: read };
8023
+ return { ok: true, entry: getFleetHome(read.homes, accountCode), lastAccount: read.lastAccount };
8024
+ };
8025
+
8026
+ /** Record the fleet this account is actually using; never overwrite a different remembered one. */
8027
+ const rememberHome = (accountCode, fleetKey) => {
8028
+ if (fleetHomeFile === null || typeof accountCode !== 'string' || accountCode === '') return;
8029
+ const written = rememberFleetHome(fleetHomeFile, { accountCode, fleetKey, lastAccount: accountCode });
8030
+ if (written.ok !== true) {
8031
+ ctx.logger?.warn?.(`w2m: could not record the fleet home (${written.code}: ${written.error})`);
8032
+ }
8033
+ };
8034
+
8035
+ /** Record only the account, for the case where a guard kept the fleet unchanged. */
8036
+ const rememberAccount = (accountCode) => {
8037
+ if (fleetHomeFile === null || typeof accountCode !== 'string' || accountCode === '') return;
8038
+ const written = rememberFleetAccount(fleetHomeFile, accountCode);
8039
+ if (written.ok !== true) {
8040
+ ctx.logger?.warn?.(`w2m: could not record the last account (${written.code}: ${written.error})`);
8041
+ }
8042
+ };
8043
+
7931
8044
  /**
7932
8045
  * How many derivation tries the **mount path** may spend.
7933
8046
  *
@@ -7962,10 +8075,25 @@ async function startLocalStack(ctx, cfg) {
7962
8075
  */
7963
8076
  async function deriveFleetKeyOnce(attempts = FAST_DERIVE_ATTEMPTS) {
7964
8077
  const configured = fleetKeyFromConfig;
7965
- if (configured !== null) return { fleetKey: configured, fleetKeySource: 'config', last: null };
8078
+ const hasExistingToken = typeof loaded?.identity?.device_token === 'string' && loaded.identity.device_token !== '';
8079
+ /**
8080
+ * v2.2.0: the configured key is a **fallback** unless the operator pins it.
8081
+ *
8082
+ * Before this, a key in the profile returned immediately and the account was never read.
8083
+ * That is why a machine with an old key stayed in the previous account's fleet even after
8084
+ * v2.1.10/v2.1.11 taught the binding code to compare and re-pair: it never got a new key to
8085
+ * compare with. The default policy now reads the account first; `fleetKeyPolicy: 'config'`
8086
+ * restores the explicit pin for fleets that outlive the signed-in account.
8087
+ */
8088
+ if (cfg.fleetKeyPolicy === 'config' && configured !== null) {
8089
+ return { fleetKey: configured, fleetKeySource: 'config', last: null };
8090
+ }
7966
8091
 
8092
+ // A configured key is a usable fallback, so do not spend the full retry budget before
8093
+ // using it. One account read is enough at mount; the watcher retries if the profile is cold.
8094
+ const effectiveAttempts = configured !== null ? 1 : attempts;
7967
8095
  let last = null;
7968
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
8096
+ for (let attempt = 1; attempt <= effectiveAttempts; attempt += 1) {
7969
8097
  try {
7970
8098
  const { createAccountIdentity } = await import('../chatops/account.mjs');
7971
8099
  last = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
@@ -7982,26 +8110,95 @@ async function startLocalStack(ctx, cfg) {
7982
8110
  */
7983
8111
  const derived = last && typeof last.fleetKey === 'string' && last.fleetKey !== '' ? last.fleetKey : null;
7984
8112
  if (derived !== null) {
7985
- ctx.logger?.info?.(
7986
- `w2m: fleet key derived from the signed-in account on attempt ${attempt} (fleet ${last.accountCode})`
7987
- + (last.ok === true ? '' : `; no serial number: ${last.reason ?? 'unknown reason'}`),
7988
- );
7989
- return { fleetKey: derived, fleetKeySource: 'account', last };
8113
+ const remembered = homeFor(last.accountCode);
8114
+ if (remembered.ok !== true && cfg.fleetKeyPolicy === 'home') {
8115
+ ctx.logger?.warn?.(`w2m: fleet home is unreadable (${remembered.error.code}: ${remembered.error.error}); not deriving a replacement fleet`);
8116
+ return {
8117
+ fleetKey: null,
8118
+ fleetKeySource: null,
8119
+ fleetKeyReason: `fleet_home_unreadable: ${remembered.error.code}`,
8120
+ last,
8121
+ };
8122
+ }
8123
+ if (remembered.ok !== true) {
8124
+ ctx.logger?.warn?.(`w2m: fleet home is unreadable (${remembered.error.code}); continuing with the account key because policy is ${cfg.fleetKeyPolicy}`);
8125
+ }
8126
+ if (cfg.fleetKeyPolicy === 'home' && remembered.ok === true && remembered.entry === null) {
8127
+ const accountChanged = remembered.lastAccount !== null && remembered.lastAccount !== last.accountCode;
8128
+ if (configured !== null) {
8129
+ // A legacy explicit key is the only evidence of the fleet this machine was already in.
8130
+ // Seed the home with it rather than deriving an account fleet and moving the machine.
8131
+ rememberHome(last.accountCode, configured);
8132
+ ctx.logger?.info?.(`w2m: first home for account ${last.accountCode} seeded from the configured fleet key`);
8133
+ return { fleetKey: configured, fleetKeySource: 'config', last };
8134
+ }
8135
+ if (shouldPreserveExistingFleet({
8136
+ policy: cfg.fleetKeyPolicy,
8137
+ hasHome: false,
8138
+ hasConfiguredKey: false,
8139
+ hasToken: hasExistingToken,
8140
+ accountChanged,
8141
+ })) {
8142
+ rememberAccount(last.accountCode);
8143
+ ctx.logger?.warn?.(
8144
+ 'w2m: this machine is paired but has no fleet home for this account, so it will stay in its current fleet; '
8145
+ + 'set the old fleetKey to seed a home (do not derive a new account fleet)',
8146
+ );
8147
+ return {
8148
+ fleetKey: null,
8149
+ fleetKeySource: null,
8150
+ fleetKeyReason: 'home_missing_existing_token',
8151
+ last,
8152
+ };
8153
+ }
8154
+ }
8155
+ const picked = selectFleetKey({
8156
+ configured,
8157
+ account: derived,
8158
+ home: remembered.ok === true ? remembered.entry?.fleet_key ?? null : null,
8159
+ policy: cfg.fleetKeyPolicy,
8160
+ });
8161
+ if (picked.fleetKey !== null) {
8162
+ if (picked.source === 'home') {
8163
+ rememberAccount(last.accountCode);
8164
+ ctx.logger?.info?.(`w2m: returning to the fleet home for account ${last.accountCode}`);
8165
+ } else {
8166
+ if (configured !== null && configured !== derived) {
8167
+ 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, or home to return to this account’s previous fleet)');
8168
+ }
8169
+ ctx.logger?.info?.(
8170
+ `w2m: fleet key derived from the signed-in account on attempt ${attempt} (fleet ${last.accountCode})`
8171
+ + (last.ok === true ? '' : `; no serial number: ${last.reason ?? 'unknown reason'}`),
8172
+ );
8173
+ // First time this account is seen (or policy is explicitly account): remember the
8174
+ // fleet it actually joins. An existing home is never overwritten while policy is
8175
+ // account, so an opt-in to account following cannot destroy the history.
8176
+ if (remembered.ok === true && remembered.entry === null) rememberHome(last.accountCode, picked.fleetKey);
8177
+ else rememberAccount(last.accountCode);
8178
+ }
8179
+ return { fleetKey: picked.fleetKey, fleetKeySource: picked.source, last };
8180
+ }
7990
8181
  }
7991
- if (attempt < attempts) {
8182
+ if (attempt < effectiveAttempts) {
7992
8183
  const waitMs = Math.min(2000 * 2 ** (attempt - 1), 30000);
7993
8184
  ctx.logger?.info?.(`w2m: no fleet key yet (${describeIdentityFailure(last)}); retrying in ${waitMs}ms`);
7994
8185
  await new Promise((resolve) => { setTimeout(resolve, waitMs); });
7995
8186
  }
7996
8187
  }
8188
+ // Account unavailable: the configured key, if any, is the documented fallback.
8189
+ const fallback = selectFleetKey({ configured, account: null, policy: cfg.fleetKeyPolicy });
8190
+ if (fallback.fleetKey !== null) {
8191
+ 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)');
8192
+ }
7997
8193
  // Not fatal: the caller may pair by code, and a later attempt may still succeed.
7998
- return { fleetKey: null, fleetKeySource: null, last };
8194
+ return { fleetKey: fallback.fleetKey, fleetKeySource: fallback.source, last };
7999
8195
  }
8000
8196
 
8001
- let { fleetKey, fleetKeySource } = await deriveFleetKeyOnce();
8197
+ let { fleetKey, fleetKeySource, last: fleetKeyIdentity } = await deriveFleetKeyOnce();
8002
8198
  resolvedFleetKey.value = fleetKey;
8003
8199
  resolvedFleetKey.source = fleetKeySource;
8004
8200
  resolvedFleetKey.at = Date.now();
8201
+ resolvedFleetKey.accountCode = fleetKeyIdentity?.accountCode ?? null;
8005
8202
 
8006
8203
  /**
8007
8204
  * Name what actually went wrong, including the shapes that used to say nothing.
@@ -8308,19 +8505,59 @@ async function startLocalStack(ctx, cfg) {
8308
8505
  let accountWatch = null;
8309
8506
  let accountWatchBusy = false;
8310
8507
  async function recheckAccountBinding() {
8311
- if (accountWatchBusy || fleetKeyFromConfig !== null) return;
8508
+ if (accountWatchBusy || cfg.fleetKeyPolicy === 'config') return;
8312
8509
  accountWatchBusy = true;
8313
8510
  try {
8314
8511
  const { createAccountIdentity } = await import('../chatops/account.mjs');
8315
8512
  const identity = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
8316
- const next = typeof identity?.fleetKey === 'string' && identity.fleetKey !== '' ? identity.fleetKey : null;
8317
- if (next === null || next === fleetKey) return;
8318
- ctx.logger?.warn?.('w2m: the signed-in account changed; leaving the previous fleet and re-pairing with the new account fleet key');
8319
- fleetKey = next;
8320
- fleetKeySource = 'account';
8321
- resolvedFleetKey.value = next;
8322
- resolvedFleetKey.source = 'account';
8513
+ const derived = typeof identity?.fleetKey === 'string' && identity.fleetKey !== '' ? identity.fleetKey : null;
8514
+ if (derived === null) return;
8515
+ const remembered = homeFor(identity.accountCode);
8516
+ if (remembered.ok !== true && cfg.fleetKeyPolicy === 'home') {
8517
+ ctx.logger?.warn?.(`w2m: account changed but the fleet home is unreadable (${remembered.error.code}); not switching fleets`);
8518
+ return;
8519
+ }
8520
+ if (remembered.ok !== true) {
8521
+ ctx.logger?.warn?.(`w2m: fleet home is unreadable (${remembered.error.code}); continuing with the account key because policy is ${cfg.fleetKeyPolicy}`);
8522
+ }
8523
+ const hasExistingToken = typeof loaded?.identity?.device_token === 'string' && loaded.identity.device_token !== '';
8524
+ const accountChanged = remembered.ok === true
8525
+ && remembered.lastAccount !== null
8526
+ && identity.accountCode !== remembered.lastAccount;
8527
+ if (shouldPreserveExistingFleet({
8528
+ policy: cfg.fleetKeyPolicy,
8529
+ hasHome: remembered.ok === true && remembered.entry !== null,
8530
+ hasConfiguredKey: fleetKeyFromConfig !== null,
8531
+ hasToken: hasExistingToken,
8532
+ accountChanged,
8533
+ })) {
8534
+ // Upgrading a paired machine with no home must not move it. A real account switch is
8535
+ // different: rule 1 says the first appearance of account B may join B's fleet.
8536
+ if (remembered.lastAccount === null) rememberAccount(identity.accountCode);
8537
+ ctx.logger?.warn?.('w2m: this machine has no fleet home and the account did not change; staying in its current fleet');
8538
+ return;
8539
+ }
8540
+ const picked = selectFleetKey({
8541
+ configured: fleetKeyFromConfig,
8542
+ account: derived,
8543
+ home: remembered.ok === true ? remembered.entry?.fleet_key ?? null : null,
8544
+ policy: cfg.fleetKeyPolicy,
8545
+ });
8546
+ if (picked.fleetKey === null || picked.fleetKey === fleetKey) return;
8547
+ if (picked.source === 'home') {
8548
+ rememberAccount(identity.accountCode);
8549
+ ctx.logger?.warn?.(`w2m: the signed-in account changed; returning to this account’s fleet home (${identity.accountCode})`);
8550
+ } else {
8551
+ ctx.logger?.warn?.('w2m: the signed-in account changed; first time for this account, joining its fleet (other machines are not moved)');
8552
+ if (remembered.ok === true && remembered.entry === null) rememberHome(identity.accountCode, picked.fleetKey);
8553
+ else rememberAccount(identity.accountCode);
8554
+ }
8555
+ fleetKey = picked.fleetKey;
8556
+ fleetKeySource = picked.source;
8557
+ resolvedFleetKey.value = picked.fleetKey;
8558
+ resolvedFleetKey.source = picked.source;
8323
8559
  resolvedFleetKey.at = Date.now();
8560
+ resolvedFleetKey.accountCode = identity.accountCode ?? resolvedFleetKey.accountCode;
8324
8561
  alreadyPaired = false;
8325
8562
  joinSucceeded = false;
8326
8563
  joinPromise = null;