@twinsearth/w2m-dsh-plugin 2.2.0 → 2.2.2

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
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.2.0';
299
+ const PLUGIN_VERSION = '2.2.2';
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,22 +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)),
802
843
  /**
803
- * v2.2.0: which key wins when a configured key and the signed-in account disagree.
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.
804
846
  *
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.
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.
809
850
  */
810
- fleetKeyPolicy: (config.fleetKeyPolicy === 'config' || process.env.W2M_FLEET_KEY_POLICY === 'config')
811
- ? 'config'
812
- : 'account',
851
+ fleetKeyPolicy: fleetPin?.policy ?? resolveFleetKeyPolicy(config, process.env),
852
+ fleetConfigFile,
813
853
  // From the merged layers, not from `config` alone: this is the value the fleet shares.
814
854
  allowedCommands: shared.values.allowedCommands
815
855
  .filter((entry) => typeof entry === 'string')
@@ -3621,6 +3661,8 @@ export async function apply(ctx, config = {}) {
3621
3661
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3622
3662
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3623
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) });
3624
3666
  // A3/S4: local-user authorization of the artifact sink. Not reachable from any tool schema.
3625
3667
  const _dataplaneSinkRoute = registerDataplaneSinkRoute(ctx, { stateDir: () => resolveStateDir(cfg.stateDir, 'joint-dev') });
3626
3668
  // A6: read-only project SSE proxy (device token stays in the host process).
@@ -5088,7 +5130,12 @@ const result = await request({
5088
5130
  rabbit_source: cfg.rabbitSource,
5089
5131
  rabbit_url: cfg.rabbitUrl,
5090
5132
  p2p: p2pStatus,
5091
- 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
+ },
5092
5139
  rtt,
5093
5140
  config: {
5094
5141
  rabbitUrl: cfg.rabbitUrl,
@@ -5101,6 +5148,8 @@ const result = await request({
5101
5148
  pairingCode_configured: cfg.pairingCode !== null,
5102
5149
  fleetKey_configured: cfg.fleetKey !== null,
5103
5150
  fleetKey_policy: cfg.fleetKeyPolicy,
5151
+ fleet_home_file: resolvedFleetKey.homeFile ?? null,
5152
+ fleet_config_file: cfg.fleetConfigFile ?? null,
5104
5153
  p2pMode: cfg.p2pMode,
5105
5154
  // The resolved list, shared server first, exactly as the node will query it.
5106
5155
  stunServers: cfg.stunServers,
@@ -7619,7 +7668,7 @@ function sleepWithSignal(ms, signal) {
7619
7668
  * a machine that joined by account -- and the panel then refused every request with "no fleet-chat identity yet".
7620
7669
  * A holder rather than a parameter because the route is mounted before the stack starts, and reads lazily.
7621
7670
  */
7622
- const resolvedFleetKey = { value: null, source: null, at: 0 };
7671
+ const resolvedFleetKey = { value: null, source: null, at: 0, homeFile: null, accountCode: null };
7623
7672
 
7624
7673
  /** Probe one relay /healthz; any failure is `false` (a timeout must not throw into the interval). */
7625
7674
  async function probeRelayHealth(url, timeoutMs) {
@@ -7719,6 +7768,16 @@ function agentIdentityOf(agent) {
7719
7768
  }
7720
7769
  }
7721
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
+ }
7722
7781
 
7723
7782
  /**
7724
7783
  * v1.5.2: bootstrap relay assignment. A fresh install has no relay list; without this it falls
@@ -7951,6 +8010,37 @@ async function startLocalStack(ctx, cfg) {
7951
8010
  */
7952
8011
  const fleetKeyFromConfig = cfg.fleetKey ?? null;
7953
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
+
7954
8044
  /**
7955
8045
  * How many derivation tries the **mount path** may spend.
7956
8046
  *
@@ -7985,6 +8075,7 @@ async function startLocalStack(ctx, cfg) {
7985
8075
  */
7986
8076
  async function deriveFleetKeyOnce(attempts = FAST_DERIVE_ATTEMPTS) {
7987
8077
  const configured = fleetKeyFromConfig;
8078
+ const hasExistingToken = typeof loaded?.identity?.device_token === 'string' && loaded.identity.device_token !== '';
7988
8079
  /**
7989
8080
  * v2.2.0: the configured key is a **fallback** unless the operator pins it.
7990
8081
  *
@@ -8019,14 +8110,74 @@ async function startLocalStack(ctx, cfg) {
8019
8110
  */
8020
8111
  const derived = last && typeof last.fleetKey === 'string' && last.fleetKey !== '' ? last.fleetKey : null;
8021
8112
  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)');
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 };
8024
8180
  }
8025
- ctx.logger?.info?.(
8026
- `w2m: fleet key derived from the signed-in account on attempt ${attempt} (fleet ${last.accountCode})`
8027
- + (last.ok === true ? '' : `; no serial number: ${last.reason ?? 'unknown reason'}`),
8028
- );
8029
- return { fleetKey: derived, fleetKeySource: 'account', last };
8030
8181
  }
8031
8182
  if (attempt < effectiveAttempts) {
8032
8183
  const waitMs = Math.min(2000 * 2 ** (attempt - 1), 30000);
@@ -8043,10 +8194,11 @@ async function startLocalStack(ctx, cfg) {
8043
8194
  return { fleetKey: fallback.fleetKey, fleetKeySource: fallback.source, last };
8044
8195
  }
8045
8196
 
8046
- let { fleetKey, fleetKeySource } = await deriveFleetKeyOnce();
8197
+ let { fleetKey, fleetKeySource, last: fleetKeyIdentity } = await deriveFleetKeyOnce();
8047
8198
  resolvedFleetKey.value = fleetKey;
8048
8199
  resolvedFleetKey.source = fleetKeySource;
8049
8200
  resolvedFleetKey.at = Date.now();
8201
+ resolvedFleetKey.accountCode = fleetKeyIdentity?.accountCode ?? null;
8050
8202
 
8051
8203
  /**
8052
8204
  * Name what actually went wrong, including the shapes that used to say nothing.
@@ -8358,14 +8510,54 @@ async function startLocalStack(ctx, cfg) {
8358
8510
  try {
8359
8511
  const { createAccountIdentity } = await import('../chatops/account.mjs');
8360
8512
  const identity = await createAccountIdentity({ ctx, platform: process.platform }).resolve();
8361
- const next = typeof identity?.fleetKey === 'string' && identity.fleetKey !== '' ? identity.fleetKey : null;
8362
- if (next === null || next === fleetKey) return;
8363
- ctx.logger?.warn?.('w2m: the signed-in account changed; leaving the previous fleet and re-pairing with the new account fleet key');
8364
- fleetKey = next;
8365
- fleetKeySource = 'account';
8366
- resolvedFleetKey.value = next;
8367
- 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;
8368
8559
  resolvedFleetKey.at = Date.now();
8560
+ resolvedFleetKey.accountCode = identity.accountCode ?? resolvedFleetKey.accountCode;
8369
8561
  alreadyPaired = false;
8370
8562
  joinSucceeded = false;
8371
8563
  joinPromise = null;