@twinsearth/w2m-dsh-plugin 2.1.9 → 2.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.1.10/ACCOUNT-SWITCH-21100.json +20 -0
- package/docs/acceptance/2.1.10/REMOTE-VIEW-CAPTURE-21100.json +20 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/agent/view/capture.mjs +6 -2
- package/src/plugin/fleet-binding.mjs +29 -6
- package/src/plugin/tools.mjs +97 -8
|
@@ -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.
|
|
8
|
+
"version": "2.1.10"
|
|
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.
|
|
15
|
+
"version": "2.1.10",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.1.
|
|
29
|
+
"version": "2.1.10"
|
|
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.
|
|
4
|
+
"version": "2.1.10",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.1.10] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- Fleet binding diagnostics: FLEET_MISMATCH names a binding to a different account's fleet; FLEET_UNBOUND keeps the existing re-admit rule.
|
|
14
|
+
- Remote view capture: read the `spawnSync` exit code from `status`, not the execFile-shaped `code`. `code` is `undefined` for `spawnSync`, so every successful screen capture was reported as CAPTURE_DENIED and the relay view target could never publish a frame. S2 used `status`, which is why its probe passed while the agent path failed.
|
|
15
|
+
- Tests: mismatch and unbound re-admit cases in test/fleet-binding.test.mjs; a capture test pins the `status` contract and would fail on the old `code` read.
|
|
16
|
+
|
|
10
17
|
## [2.1.9] - 2026-10-31
|
|
11
18
|
|
|
12
19
|
- Plugin identity: exported locale/zh.json + locale/en.json meta.title/meta.description. DSH reads these without activating the plugin, so the manager list and detail page render the custom name and copy.
|
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.
|
|
95
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.10", "w2m-mcp"]
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.1.10",
|
|
3
|
+
"case": "ACCOUNT-SWITCH-21100",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T18:03:23.303Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "account-switch-21100-1793469803304",
|
|
11
|
+
"requirement": "Switching the signed-in DeepSeek account must move the machine to that account fleet, not leave it heartbeating in the previous fleet",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"mismatch": "FLEET_MISMATCH names a relay fleet_id that differs from the account-derived fleet_id",
|
|
16
|
+
"rejoin": "shouldRejoin re-admits on null and on mismatch, and not when the ids agree",
|
|
17
|
+
"watch": "a 60s account check re-derives the fleet key and re-pairs after a switch"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.1.10",
|
|
3
|
+
"case": "REMOTE-VIEW-CAPTURE-21100",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b1b19d49-20f1-4cdc-986e-f5630a5cd6f2"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T18:11:48.141Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "remote-view-capture-21100-1793470308142",
|
|
11
|
+
"requirement": "The relay view target must read spawnSync.status so a successful screen capture is published instead of CAPTURE_DENIED",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"status": "captureScreen reads result.status on darwin and win32",
|
|
16
|
+
"regression": "a runner returning {status:0,code:1} still captures",
|
|
17
|
+
"probe": "remote-s2-capture exit 162 on macmini"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.10",
|
|
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.
|
|
4
|
+
"version": "2.1.10",
|
|
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": [
|
|
@@ -74,7 +74,9 @@ export function captureScreen(options = {}) {
|
|
|
74
74
|
let height = null;
|
|
75
75
|
if (platform === 'win32') {
|
|
76
76
|
const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
|
|
77
|
-
|
|
77
|
+
// `spawnSync` reports the exit code as `status`; `code` is the execFile-shaped field
|
|
78
|
+
// and is undefined here, which made every successful capture look like a denial.
|
|
79
|
+
if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.status ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
|
|
78
80
|
base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
79
81
|
if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
|
|
80
82
|
} else if (platform === 'darwin') {
|
|
@@ -82,7 +84,9 @@ export function captureScreen(options = {}) {
|
|
|
82
84
|
const file = join(dir, 'frame.jpg');
|
|
83
85
|
try {
|
|
84
86
|
const result = run(['/usr/sbin/screencapture', '-x', '-t', 'jpg', file], { timeoutMs: 15000 });
|
|
85
|
-
|
|
87
|
+
// Same `spawnSync` contract: `status`, never `code`. S2 used the right field, which is
|
|
88
|
+
// why the probe succeeded while the relay view target reported CAPTURE_DENIED.
|
|
89
|
+
if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.status})`, capture_ms: Date.now() - started };
|
|
86
90
|
const data = readFileSync(file);
|
|
87
91
|
base64 = data.toString('base64');
|
|
88
92
|
const sips = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 5000 });
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export const FLEET_BINDING_CODE = Object.freeze({
|
|
14
14
|
BOUND: 'FLEET_BOUND',
|
|
15
15
|
UNBOUND: 'FLEET_UNBOUND',
|
|
16
|
+
MISMATCH: 'FLEET_MISMATCH',
|
|
16
17
|
UNPAIRED: 'FLEET_UNPAIRED',
|
|
17
18
|
TOKEN_REJECTED: 'FLEET_TOKEN_REJECTED',
|
|
18
19
|
UNKNOWN: 'FLEET_UNKNOWN',
|
|
@@ -20,12 +21,22 @@ export const FLEET_BINDING_CODE = Object.freeze({
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Should a start-up re-admit even though a token exists?
|
|
23
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* Two states need it: the machine has no fleet (a token issued before fleet binding, or a
|
|
26
|
+
* run where the pairing code carried none), or the fleet it is in is **not the fleet the
|
|
27
|
+
* current account derives**. The second is the account-switch defect: a stored token stays
|
|
28
|
+
* valid for the account that minted it, so after a different DeepSeek account signs in the
|
|
29
|
+
* machine keeps heartbeating into the old account's fleet unless its binding is compared
|
|
30
|
+
* with the key the new account derives.
|
|
31
|
+
*
|
|
32
|
+
* An absent expected fleet id means the caller could not derive one; equality cannot be
|
|
33
|
+
* tested then, so only the unbound state re-admits.
|
|
24
34
|
*/
|
|
25
|
-
export function shouldRejoin({ token, fleetId, fleetKey }) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null }) {
|
|
36
|
+
if (typeof token !== 'string' || token === '') return false;
|
|
37
|
+
if (typeof fleetKey !== 'string' || fleetKey.trim() === '') return false;
|
|
38
|
+
if (fleetId === null || fleetId === undefined || fleetId === '') return true;
|
|
39
|
+
return typeof expectedFleetId === 'string' && expectedFleetId !== '' && fleetId !== expectedFleetId;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/**
|
|
@@ -35,14 +46,16 @@ export function shouldRejoin({ token, fleetId, fleetKey }) {
|
|
|
35
46
|
* fleetKey: string|null, fetchImpl?: Function, timeoutMs?: number}} input
|
|
36
47
|
* @returns {Promise<object>} a bounded, token-free status block
|
|
37
48
|
*/
|
|
38
|
-
export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, fetchImpl = fetch, timeoutMs = 2500 }) {
|
|
49
|
+
export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, expectedFleetId = null, fetchImpl = fetch, timeoutMs = 2500 }) {
|
|
39
50
|
const checkedAt = new Date().toISOString();
|
|
40
51
|
const fleetKeyConfigured = typeof fleetKey === 'string' && fleetKey.trim() !== '';
|
|
52
|
+
const expected = typeof expectedFleetId === 'string' && expectedFleetId !== '' ? expectedFleetId : null;
|
|
41
53
|
const base = {
|
|
42
54
|
ok: true,
|
|
43
55
|
state: 'unknown',
|
|
44
56
|
code: FLEET_BINDING_CODE.UNKNOWN,
|
|
45
57
|
fleet_id: null,
|
|
58
|
+
expected_fleet_id: expected,
|
|
46
59
|
token_present: typeof token === 'string' && token !== '',
|
|
47
60
|
fleet_key_configured: fleetKeyConfigured,
|
|
48
61
|
machine_id: machineId,
|
|
@@ -93,6 +106,16 @@ export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKe
|
|
|
93
106
|
: 'set fleetKey / W2M_FLEET_KEY, then restart the agent so the machine can join its fleet',
|
|
94
107
|
};
|
|
95
108
|
}
|
|
109
|
+
if (expected !== null && fleetId !== expected) {
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
state: 'mismatch',
|
|
113
|
+
code: FLEET_BINDING_CODE.MISMATCH,
|
|
114
|
+
fleet_id: fleetId,
|
|
115
|
+
detail: 'the relay records this machine in a different fleet than the signed-in account derives',
|
|
116
|
+
remedy: 're-admit this machine with the account-derived fleet key; the old token must not keep it in the previous fleet',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
96
119
|
return { ...base, state: 'bound', code: FLEET_BINDING_CODE.BOUND, fleet_id: fleetId };
|
|
97
120
|
} catch (error) {
|
|
98
121
|
return {
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
296
|
+
const PLUGIN_VERSION = '2.1.10';
|
|
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;
|
|
@@ -744,7 +744,7 @@ function readConfig(config = {}) {
|
|
|
744
744
|
// silent default when a value *is* supplied.
|
|
745
745
|
const p2p = resolveP2PMode(config);
|
|
746
746
|
const p2pMode = p2p.mode;
|
|
747
|
-
const p2pPortResult = normalizeP2PPort(config.p2pPort);
|
|
747
|
+
const p2pPortResult = normalizeP2PPort(config.p2pPort);
|
|
748
748
|
const p2pPublicPortResult = normalizeP2PPort(config.p2pPublicPort);
|
|
749
749
|
const stun = resolveStunServers(config);
|
|
750
750
|
const stunList = stun.servers;
|
|
@@ -934,7 +934,7 @@ function readConfig(config = {}) {
|
|
|
934
934
|
: (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
|
|
935
935
|
p2pMode,
|
|
936
936
|
p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
|
|
937
|
-
p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
|
|
937
|
+
p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
|
|
938
938
|
p2pPublicPort: p2pPublicPortResult.ok ? p2pPublicPortResult.port : null,
|
|
939
939
|
stunServers: stunList,
|
|
940
940
|
};
|
|
@@ -2037,7 +2037,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
2037
2037
|
getJson: transport.getJson,
|
|
2038
2038
|
stunServers: cfg.stunServers,
|
|
2039
2039
|
log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
|
|
2040
|
-
...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
|
|
2040
|
+
...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
|
|
2041
2041
|
...(Number.isInteger(cfg.p2pPublicPort) ? { publicPort: cfg.p2pPublicPort } : {}),
|
|
2042
2042
|
...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
|
|
2043
2043
|
...block,
|
|
@@ -5021,11 +5021,25 @@ const result = await request({
|
|
|
5021
5021
|
};
|
|
5022
5022
|
})();
|
|
5023
5023
|
|
|
5024
|
+
/**
|
|
5025
|
+
* Compare against the key the stack actually resolved, not just `cfg.fleetKey`.
|
|
5026
|
+
* A machine joined by account has no configured key, and that is exactly the case
|
|
5027
|
+
* where a switched account used to look "bound" because only `fleet_id=null` was
|
|
5028
|
+
* treated as wrong.
|
|
5029
|
+
*/
|
|
5030
|
+
let expectedFleetId = null;
|
|
5031
|
+
const statusFleetKey = resolvedFleetKey.value ?? cfg.fleetKey;
|
|
5032
|
+
try {
|
|
5033
|
+
const { planJoin } = await import('../chatops/fleet-join.mjs');
|
|
5034
|
+
const plan = planJoin({ fleetKeyEncoded: statusFleetKey });
|
|
5035
|
+
if (plan.ok === true) expectedFleetId = plan.fleetId;
|
|
5036
|
+
} catch { /* malformed key: report without an expectation */ }
|
|
5024
5037
|
const fleetBinding = await diagnoseFleetBinding({
|
|
5025
5038
|
relayUrl: cfg.rabbitUrl,
|
|
5026
5039
|
token: device.device_token,
|
|
5027
5040
|
machineId: device.machine_id,
|
|
5028
5041
|
fleetKey: cfg.fleetKey,
|
|
5042
|
+
expectedFleetId,
|
|
5029
5043
|
});
|
|
5030
5044
|
|
|
5031
5045
|
return JSON.stringify(
|
|
@@ -8062,16 +8076,38 @@ async function startLocalStack(ctx, cfg) {
|
|
|
8062
8076
|
if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
|
|
8063
8077
|
ctx.logger?.warn?.('w2m: stored device token was rejected by ' + relayForToken + ' (HTTP ' + probe.status + '); pairing again with the fleet key');
|
|
8064
8078
|
}
|
|
8065
|
-
|
|
8079
|
+
/**
|
|
8080
|
+
* Compare the relay's binding with the fleet the **current** key derives.
|
|
8081
|
+
*
|
|
8082
|
+
* The old check ran only for a configured `fleetKey`, so the account-derived key
|
|
8083
|
+
* (the normal case) was never compared. That is the account-switch defect: the
|
|
8084
|
+
* stored token stays valid for the account that minted it, and the machine keeps
|
|
8085
|
+
* heartbeating into the previous account's fleet. `fleetId=null` was the only
|
|
8086
|
+
* rejoin trigger, so a non-null mismatch looked perfectly healthy.
|
|
8087
|
+
*/
|
|
8088
|
+
const effectiveFleetKey = fleetKey ?? fleetKeyFromConfig;
|
|
8089
|
+
if (alreadyPaired && effectiveFleetKey !== null) {
|
|
8090
|
+
let expectedFleetId = null;
|
|
8091
|
+
try {
|
|
8092
|
+
const { planJoin } = await import('../chatops/fleet-join.mjs');
|
|
8093
|
+
const plan = planJoin({ fleetKeyEncoded: effectiveFleetKey });
|
|
8094
|
+
if (plan.ok === true) expectedFleetId = plan.fleetId;
|
|
8095
|
+
} catch { /* malformed key: binding cannot be compared, keep the token */ }
|
|
8066
8096
|
const binding = await diagnoseFleetBinding({
|
|
8067
8097
|
relayUrl: relayForToken,
|
|
8068
8098
|
token: existingToken,
|
|
8069
8099
|
machineId: loaded.identity.machine_id,
|
|
8070
|
-
fleetKey:
|
|
8100
|
+
fleetKey: effectiveFleetKey,
|
|
8101
|
+
expectedFleetId,
|
|
8071
8102
|
});
|
|
8072
|
-
if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey:
|
|
8103
|
+
if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: effectiveFleetKey, expectedFleetId })) {
|
|
8073
8104
|
alreadyPaired = false;
|
|
8074
|
-
|
|
8105
|
+
loaded.identity.device_token = null;
|
|
8106
|
+
if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
|
|
8107
|
+
ctx.logger?.warn?.(
|
|
8108
|
+
`w2m: fleet binding changed (relay=${binding.fleet_id ?? 'none'}, account=${expectedFleetId ?? 'unknown'}); `
|
|
8109
|
+
+ `re-admitting with the ${fleetKeySource === 'config' ? 'configured' : 'account-derived'} fleet key`,
|
|
8110
|
+
);
|
|
8075
8111
|
}
|
|
8076
8112
|
}
|
|
8077
8113
|
} catch { /* network error: keep the token, do not force a re-pair */ }
|
|
@@ -8254,6 +8290,55 @@ async function startLocalStack(ctx, cfg) {
|
|
|
8254
8290
|
*/
|
|
8255
8291
|
ensureFleetJoin = () => joinFleet();
|
|
8256
8292
|
|
|
8293
|
+
/**
|
|
8294
|
+
* Watch for an account switch while the process stays alive.
|
|
8295
|
+
*
|
|
8296
|
+
* The mount check repairs a switch that happened before start-up. This one covers the same
|
|
8297
|
+
* event without a restart: a different DeepSeek account signs in, the account service starts
|
|
8298
|
+
* returning a different user id, and the derived fleet key changes. The stored token is still
|
|
8299
|
+
* valid for the *previous* account, so without this the machine keeps heartbeating into the
|
|
8300
|
+
* old fleet -- the reported symptom. Re-pairing rotates the token and moves the machine.
|
|
8301
|
+
*
|
|
8302
|
+
* The account service is read on an interval rather than per request: it can touch the host's
|
|
8303
|
+
* profile store, and a switch is a human-paced event, not a per-dispatch one. An operator-set
|
|
8304
|
+
* `fleetKey` is not watched -- that is an explicit decision and must not be overwritten by
|
|
8305
|
+
* whichever account happens to be signed in.
|
|
8306
|
+
*/
|
|
8307
|
+
const ACCOUNT_WATCH_MS = 60_000;
|
|
8308
|
+
let accountWatch = null;
|
|
8309
|
+
let accountWatchBusy = false;
|
|
8310
|
+
async function recheckAccountBinding() {
|
|
8311
|
+
if (accountWatchBusy || fleetKeyFromConfig !== null) return;
|
|
8312
|
+
accountWatchBusy = true;
|
|
8313
|
+
try {
|
|
8314
|
+
const { createAccountIdentity } = await import('../chatops/account.mjs');
|
|
8315
|
+
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';
|
|
8323
|
+
resolvedFleetKey.at = Date.now();
|
|
8324
|
+
alreadyPaired = false;
|
|
8325
|
+
joinSucceeded = false;
|
|
8326
|
+
joinPromise = null;
|
|
8327
|
+
joinLastError = null;
|
|
8328
|
+
joinLastWasKeyless = false;
|
|
8329
|
+
loaded.identity.device_token = null;
|
|
8330
|
+
if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
|
|
8331
|
+
const outcome = await joinFleet();
|
|
8332
|
+
if (outcome.paired !== true) scheduleJoinRetry();
|
|
8333
|
+
} catch (error) {
|
|
8334
|
+
ctx.logger?.warn?.('w2m: account re-check failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
8335
|
+
} finally {
|
|
8336
|
+
accountWatchBusy = false;
|
|
8337
|
+
}
|
|
8338
|
+
}
|
|
8339
|
+
accountWatch = setInterval(() => { void recheckAccountBinding(); }, ACCOUNT_WATCH_MS);
|
|
8340
|
+
accountWatch.unref?.();
|
|
8341
|
+
|
|
8257
8342
|
if (alreadyPaired) {
|
|
8258
8343
|
joinSucceeded = true;
|
|
8259
8344
|
joinPromise = Promise.resolve({ paired: true, by: 'existing token' });
|
|
@@ -8362,6 +8447,10 @@ async function startLocalStack(ctx, cfg) {
|
|
|
8362
8447
|
|
|
8363
8448
|
return async () => {
|
|
8364
8449
|
clearJoinRetry();
|
|
8450
|
+
if (accountWatch !== null) {
|
|
8451
|
+
clearInterval(accountWatch);
|
|
8452
|
+
accountWatch = null;
|
|
8453
|
+
}
|
|
8365
8454
|
ensureFleetJoin = null;
|
|
8366
8455
|
inProcessAgentStatus = null;
|
|
8367
8456
|
await agent.stop();
|