@twinsearth/w2m-dsh-plugin 2.1.7 → 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 +12 -0
- package/README.md +11 -2
- 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/docs/acceptance/2.1.9/RELEASE-2190.json +20 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +8 -5
- 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,18 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.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
|
+
|
|
17
|
+
## [2.1.9] - 2026-10-31
|
|
18
|
+
|
|
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.
|
|
20
|
+
- package.json exports each locale JSON explicitly and ships locale/.
|
|
21
|
+
|
|
10
22
|
## [2.1.7] - 2026-10-31
|
|
11
23
|
|
|
12
24
|
- Plugin identity: ship icon.png and package.json meta.title = "W2M:AI 智能体舰队编排工具" so a fresh install renders the custom icon/name.
|
package/README.md
CHANGED
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
English | [中文说明](#中文说明)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## W2M是一个开源(MIT)的 AI 智能体舰队编排工具
|
|
12
|
+
————用同一个 DeepSeek 、豆包、WorkBuddy账号把"你自己的多台机器"(Windows/Mac)编成一个舰队,统一派发命令、协作&协同开发。
|
|
13
|
+
### 组件有三块:
|
|
14
|
+
1.Relay(HTTP+SSE 协调服务器)
|
|
15
|
+
|
|
16
|
+
2.Agent(每台机器上跑、执行白名单命令)
|
|
17
|
+
|
|
18
|
+
3.Plugin(给 Agent 用的 18 个工具)
|
|
12
19
|
|
|
13
20
|
## 六大核心功能
|
|
14
21
|
|
|
@@ -30,6 +37,8 @@ English | [中文说明](#中文说明)
|
|
|
30
37
|
### 功能 6 · 全局文件夹读写黑白名单:保护系统盘 & 个人/工作文件夹
|
|
31
38
|
DSH 默认的权限模型是“限制 or 全开”,一旦放开就难以精细控制。本插件在官方沙箱之上追加全局文件夹读写黑白名单,为系统盘(`C:\Windows`、`Program Files`、`/etc`、`/usr` 等)和个人/工作文件夹建立明确的读/写边界,避免 AI 或跨机任务误触不该动的路径。这一设计来自实际重装系统后的教训:粗粒度的“全开”代价太高,黑白名单把“绝对不允许”与“经常要放行”的路径变成可配置的硬规则。v2.0.4 起提供两种策略:**小白模式(默认)**只保留黑名单强制拒绝(内置 Windows/Program Files 等默认保护文件夹,路径操作每 3 秒最多自动放行一次),其余全部放行;**高级模式**需手动切换,保留白名单预算、待审批队列与 10 秒无人应答自动放行。
|
|
32
39
|
|
|
40
|
+
### 功能 7 ·远程桌面(开发中)
|
|
41
|
+
|
|
33
42
|
## 安装
|
|
34
43
|
|
|
35
44
|
Every install path below registers 18 tools in your agent.
|
|
@@ -83,7 +92,7 @@ w2m-mcp --help
|
|
|
83
92
|
# 复制进你自己的 codex/mcp-servers.toml
|
|
84
93
|
[mcp_servers.w2m]
|
|
85
94
|
command = "npx"
|
|
86
|
-
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.
|
|
95
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.10", "w2m-mcp"]
|
|
87
96
|
```
|
|
88
97
|
|
|
89
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.1.9",
|
|
3
|
+
"case": "RELEASE-2190",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T07:15:33.687Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2190-1793430933689",
|
|
11
|
+
"requirement": "v2.1.9: exported locale meta.title/description renders the custom plugin name and detail copy",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"locale": "locale/zh.json + locale/en.json exported explicitly and shipped",
|
|
16
|
+
"title": "W2M:AI 智能体舰队!",
|
|
17
|
+
"description": "requested Chinese copy"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "W2M: AI Agent Fleet!",
|
|
4
|
+
"description": "W2M is an open-source (MIT) AI agent fleet orchestrator. With one DeepSeek, Doubao or WorkBuddy account, it turns your own machines (Windows/Mac) into a fleet for unified command dispatch, collaboration and joint development.\nThree components: Relay (HTTP+SSE coordination server), Agent (runs on each machine, executes allow-listed commands) and Plugin (18 tools for the agent)."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
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": {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"./stun": "./bin/w2m-stun.mjs",
|
|
13
13
|
"./package.json": "./package.json",
|
|
14
14
|
"./gpw": "./lib/gpw.js",
|
|
15
|
-
"./desktop": "./lib/desktop.js"
|
|
15
|
+
"./desktop": "./lib/desktop.js",
|
|
16
|
+
"./locale/en.json": "./locale/en.json",
|
|
17
|
+
"./locale/zh.json": "./locale/zh.json"
|
|
16
18
|
},
|
|
17
19
|
"dsh": {
|
|
18
20
|
"plugin": true,
|
|
@@ -52,7 +54,8 @@
|
|
|
52
54
|
"codex",
|
|
53
55
|
"TEST-PLAN-JOINT-FLEET-2.0.8.md",
|
|
54
56
|
"TEST-CASES-JOINT-FLEET-2.0.8.md",
|
|
55
|
-
"icon.png"
|
|
57
|
+
"icon.png",
|
|
58
|
+
"locale"
|
|
56
59
|
],
|
|
57
60
|
"engines": {
|
|
58
61
|
"node": ">=20.19.0"
|
|
@@ -270,7 +273,7 @@
|
|
|
270
273
|
},
|
|
271
274
|
"icon": "./icon.png",
|
|
272
275
|
"meta": {
|
|
273
|
-
"title": "W2M:AI
|
|
274
|
-
"description": "
|
|
276
|
+
"title": "W2M:AI 智能体舰队!",
|
|
277
|
+
"description": "W2M是一个开源(MIT)的 AI 智能体舰队编排工具——用同一个 DeepSeek 、豆包、WorkBuddy账号把\"你自己的多台机器\"(Windows/Mac)编成一个舰队,统一派发命令、协作&协同开发。\n组件有三块:Relay(HTTP+SSE 协调服务器)、Agent(每台机器上跑、执行白名单命令)、Plugin(给 Agent 用的 18 个工具)。"
|
|
275
278
|
}
|
|
276
279
|
}
|
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();
|