@twinsearth/w2m-dsh-plugin 2.2.0 → 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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.2.1/RELEASE-2210.json +31 -0
- package/lib/client.js +442 -6
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/plugin/fleet-binding.mjs +9 -12
- package/src/plugin/fleet-config-route.mjs +130 -0
- package/src/plugin/fleet-config.mjs +183 -0
- package/src/plugin/fleet-home.mjs +263 -0
- package/src/plugin/tools.mjs +220 -28
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-account fleet homes: the local answer to "which fleet was this machine in last time it was
|
|
3
|
+
* signed in to this account?"
|
|
4
|
+
*
|
|
5
|
+
* # Why a file, and why this file
|
|
6
|
+
*
|
|
7
|
+
* The account-derived key is deterministic, but it is not a complete history. A fleet can have
|
|
8
|
+
* been created with an explicit key, or before account-derived keys existed, and the derived key
|
|
9
|
+
* then points at a different (new) fleet. Replacing the old key on every account switch is what
|
|
10
|
+
* made one machine's sign-in move the whole fleet -- v2.2.0's `fleetKeyPolicy: account`.
|
|
11
|
+
*
|
|
12
|
+
* The home map is deliberately **per account**: switching from A to B does not erase A's home, so
|
|
13
|
+
* switching back to A returns to A's fleet instead of minting another one. It is written beside
|
|
14
|
+
* `device.json` (same directory, same permissions concerns) and contains encoded fleet keys, which
|
|
15
|
+
* are credentials: the file is 0600 where the platform honours it.
|
|
16
|
+
*
|
|
17
|
+
* # Failure policy
|
|
18
|
+
*
|
|
19
|
+
* `readFleetHome` never silently treats a corrupt file as "no homes". A machine in `home` policy
|
|
20
|
+
* that cannot read its history must stay unbound and report the fault rather than derive a new key
|
|
21
|
+
* and create a new fleet -- that automatic migration is the behaviour this module exists to stop.
|
|
22
|
+
* The caller decides whether to block (home policy) or continue without the history (account
|
|
23
|
+
* policy).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/** The file beside `device.json`; the basename is part of the operator-visible contract. */
|
|
30
|
+
export const FLEET_HOME_FILE = 'fleet-home.json';
|
|
31
|
+
|
|
32
|
+
/** Bump only with a reader that still understands the previous shape. */
|
|
33
|
+
export const FLEET_HOME_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
const ACCOUNT_CODE_RE = /^[0-9a-f]{8}$/;
|
|
36
|
+
const MAX_KEY_LENGTH = 4096;
|
|
37
|
+
const MAX_ACCOUNTS = 64;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string|null} deviceFile Absolute path of `device.json`, or null when unknown.
|
|
41
|
+
* @returns {string|null} The path of the home file, or null when there is no state directory.
|
|
42
|
+
*/
|
|
43
|
+
export function fleetHomePath(deviceFile) {
|
|
44
|
+
return typeof deviceFile === 'string' && deviceFile !== '' ? join(dirname(deviceFile), FLEET_HOME_FILE) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the map. Missing file means "no history yet", which is a success, not an error.
|
|
49
|
+
*
|
|
50
|
+
* @param {string|null} file
|
|
51
|
+
* @returns {{ok: true, homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null, path: string|null}
|
|
52
|
+
* | {ok: false, code: string, error: string, path: string|null}}
|
|
53
|
+
*/
|
|
54
|
+
export function readFleetHome(file) {
|
|
55
|
+
if (file === null || file === undefined || file === '') return { ok: true, homes: {}, lastAccount: null, path: null };
|
|
56
|
+
if (!existsSync(file)) return { ok: true, homes: {}, lastAccount: null, path: file };
|
|
57
|
+
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(file, 'utf8');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
code: 'FLEET_HOME_UNREADABLE',
|
|
65
|
+
error: error instanceof Error ? error.message : String(error),
|
|
66
|
+
path: file,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(text);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
code: 'FLEET_HOME_CORRUPT',
|
|
77
|
+
error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
path: file,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (parsed === null || typeof parsed !== 'object'
|
|
82
|
+
|| parsed.version !== FLEET_HOME_VERSION
|
|
83
|
+
|| parsed.homes === null || typeof parsed.homes !== 'object' || Array.isArray(parsed.homes)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: 'FLEET_HOME_CORRUPT',
|
|
87
|
+
error: `expected {version: ${FLEET_HOME_VERSION}, homes: {…}}`,
|
|
88
|
+
path: file,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const homes = {};
|
|
93
|
+
for (const [accountCode, entry] of Object.entries(parsed.homes)) {
|
|
94
|
+
if (!ACCOUNT_CODE_RE.test(accountCode)) {
|
|
95
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad account code ${accountCode}`, path: file };
|
|
96
|
+
}
|
|
97
|
+
if (entry === null || typeof entry !== 'object'
|
|
98
|
+
|| typeof entry.fleet_key !== 'string' || entry.fleet_key === ''
|
|
99
|
+
|| entry.fleet_key.length > MAX_KEY_LENGTH) {
|
|
100
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad home for ${accountCode}`, path: file };
|
|
101
|
+
}
|
|
102
|
+
homes[accountCode] = {
|
|
103
|
+
fleet_key: entry.fleet_key,
|
|
104
|
+
updated_at: typeof entry.updated_at === 'string' ? entry.updated_at : '',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (Object.keys(homes).length > MAX_ACCOUNTS) {
|
|
108
|
+
return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
|
|
109
|
+
}
|
|
110
|
+
const lastAccount = typeof parsed.last_account === 'string' && ACCOUNT_CODE_RE.test(parsed.last_account)
|
|
111
|
+
? parsed.last_account
|
|
112
|
+
: null;
|
|
113
|
+
return { ok: true, homes, lastAccount, path: file };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {Record<string, {fleet_key: string}>} homes
|
|
118
|
+
* @param {string|null} accountCode
|
|
119
|
+
* @returns {{fleet_key: string, updated_at: string}|null}
|
|
120
|
+
*/
|
|
121
|
+
export function getFleetHome(homes, accountCode) {
|
|
122
|
+
if (typeof accountCode !== 'string' || !ACCOUNT_CODE_RE.test(accountCode)) return null;
|
|
123
|
+
const entry = homes?.[accountCode];
|
|
124
|
+
return entry !== undefined && typeof entry.fleet_key === 'string' && entry.fleet_key !== '' ? entry : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Should a machine with an existing token and no recorded home stay exactly where it is?
|
|
129
|
+
*
|
|
130
|
+
* This is the migration guard for 2.2.0 -> 2.2.1. Such a machine may be sitting in an old fleet
|
|
131
|
+
* whose key no longer exists in its profile; deriving the account key at that moment is precisely
|
|
132
|
+
* how an upgrade would move it (and, if the account is shared, tempt everyone else to follow).
|
|
133
|
+
* Staying put and reporting "no home key" is recoverable; silently re-pairing is not.
|
|
134
|
+
*
|
|
135
|
+
* A configured key is excluded: it is the operator's explicit seed and is used instead of the
|
|
136
|
+
* account key. A fresh, unpaired machine is excluded too: it has no fleet to preserve.
|
|
137
|
+
*
|
|
138
|
+
* @param {{policy?: string, hasHome?: boolean, hasConfiguredKey?: boolean, hasToken?: boolean,
|
|
139
|
+
* accountChanged?: boolean}} input
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function shouldPreserveExistingFleet({
|
|
143
|
+
policy = 'home',
|
|
144
|
+
hasHome = false,
|
|
145
|
+
hasConfiguredKey = false,
|
|
146
|
+
hasToken = false,
|
|
147
|
+
accountChanged = false,
|
|
148
|
+
} = {}) {
|
|
149
|
+
return policy === 'home'
|
|
150
|
+
&& hasHome !== true
|
|
151
|
+
&& hasConfiguredKey !== true
|
|
152
|
+
&& hasToken === true
|
|
153
|
+
&& accountChanged !== true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write the complete state atomically. Internal: callers have already validated what they add.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} file
|
|
160
|
+
* @param {{homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null}} state
|
|
161
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
162
|
+
*/
|
|
163
|
+
function writeFleetState(file, state) {
|
|
164
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
167
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
168
|
+
version: FLEET_HOME_VERSION,
|
|
169
|
+
last_account: state.lastAccount,
|
|
170
|
+
homes: state.homes,
|
|
171
|
+
}, null, 2)}\n`, {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
});
|
|
175
|
+
try { chmodSync(tmp, 0o600); } catch { /* Windows: the file ACL carries the protection */ }
|
|
176
|
+
try {
|
|
177
|
+
renameSync(tmp, file);
|
|
178
|
+
} catch {
|
|
179
|
+
// Windows can refuse to replace a file that another handle has open. The old file is gone
|
|
180
|
+
// only after the copy exists, so this fallback cannot turn a failed write into no file.
|
|
181
|
+
rmSync(file, { force: true });
|
|
182
|
+
renameSync(tmp, file);
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, path: file };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
code: 'FLEET_HOME_WRITE_FAILED',
|
|
190
|
+
error: error instanceof Error ? error.message : String(error),
|
|
191
|
+
path: file,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Remember one account's fleet key. Other accounts are preserved verbatim, and the last account
|
|
198
|
+
* that successfully bound is recorded so a restart can tell "same account, no home yet" apart from
|
|
199
|
+
* "the account actually changed".
|
|
200
|
+
*
|
|
201
|
+
* The write is temp-file + rename in the same directory, so a crash leaves either the old complete
|
|
202
|
+
* map or the new complete map. A corrupt existing map is **not** overwritten: silently replacing a
|
|
203
|
+
* history we cannot parse is how the recovery data would be lost exactly when it is needed.
|
|
204
|
+
*
|
|
205
|
+
* @param {string|null} file
|
|
206
|
+
* @param {{accountCode: string, fleetKey: string, lastAccount?: string|null, now?: number}} input
|
|
207
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
208
|
+
*/
|
|
209
|
+
export function rememberFleetHome(file, input) {
|
|
210
|
+
if (file === null || file === undefined || file === '') {
|
|
211
|
+
return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
|
|
212
|
+
}
|
|
213
|
+
const accountCode = typeof input?.accountCode === 'string' ? input.accountCode : '';
|
|
214
|
+
if (!ACCOUNT_CODE_RE.test(accountCode)) {
|
|
215
|
+
return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
|
|
216
|
+
}
|
|
217
|
+
const fleetKey = typeof input?.fleetKey === 'string' ? input.fleetKey : '';
|
|
218
|
+
if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
|
|
219
|
+
return { ok: false, code: 'FLEET_HOME_BAD_KEY', error: 'fleet key is empty or too long', path: file };
|
|
220
|
+
}
|
|
221
|
+
const previous = readFleetHome(file);
|
|
222
|
+
if (previous.ok !== true) return previous;
|
|
223
|
+
const homes = {
|
|
224
|
+
...previous.homes,
|
|
225
|
+
[accountCode]: {
|
|
226
|
+
fleet_key: fleetKey,
|
|
227
|
+
updated_at: new Date(Number.isInteger(input?.now) ? input.now : Date.now()).toISOString(),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
if (Object.keys(homes).length > MAX_ACCOUNTS) {
|
|
231
|
+
return { ok: false, code: 'FLEET_HOME_TOO_MANY', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
|
|
232
|
+
}
|
|
233
|
+
const lastAccount = typeof input?.lastAccount === 'string' && ACCOUNT_CODE_RE.test(input.lastAccount)
|
|
234
|
+
? input.lastAccount
|
|
235
|
+
: accountCode;
|
|
236
|
+
if (previous.homes[accountCode]?.fleet_key === fleetKey && previous.lastAccount === lastAccount) {
|
|
237
|
+
return { ok: true, path: file };
|
|
238
|
+
}
|
|
239
|
+
return writeFleetState(file, { homes, lastAccount });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Record which account this machine last bound under, without changing any fleet key.
|
|
244
|
+
*
|
|
245
|
+
* Used by the migration guard: a paired machine that cannot derive a key still has an account
|
|
246
|
+
* identity, and persisting it is what lets the *next* real account switch be recognised.
|
|
247
|
+
*
|
|
248
|
+
* @param {string|null} file
|
|
249
|
+
* @param {string} accountCode
|
|
250
|
+
* @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
|
|
251
|
+
*/
|
|
252
|
+
export function rememberFleetAccount(file, accountCode) {
|
|
253
|
+
if (file === null || file === undefined || file === '') {
|
|
254
|
+
return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
|
|
255
|
+
}
|
|
256
|
+
if (!ACCOUNT_CODE_RE.test(typeof accountCode === 'string' ? accountCode : '')) {
|
|
257
|
+
return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
|
|
258
|
+
}
|
|
259
|
+
const previous = readFleetHome(file);
|
|
260
|
+
if (previous.ok !== true) return previous;
|
|
261
|
+
if (previous.lastAccount === accountCode) return { ok: true, path: file };
|
|
262
|
+
return writeFleetState(file, { homes: previous.homes, lastAccount: accountCode });
|
|
263
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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,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.
|
|
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
|
-
* `
|
|
806
|
-
*
|
|
807
|
-
*
|
|
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:
|
|
811
|
-
|
|
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:
|
|
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
|
-
|
|
8023
|
-
|
|
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
|
|
8362
|
-
if (
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
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;
|