@twinsearth/w2m-dsh-plugin 1.5.4 → 1.5.5
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 +17 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.5.5/FLEET-ISOLATION.json +23 -0
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +36 -12
- package/src/relay/state.mjs +10 -1
|
@@ -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": "1.5.
|
|
8
|
+
"version": "1.5.5"
|
|
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": "1.5.
|
|
15
|
+
"version": "1.5.5",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.5.
|
|
29
|
+
"version": "1.5.5"
|
|
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": "1.5.
|
|
4
|
+
"version": "1.5.5",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.5.5] — 2026-10-23
|
|
11
|
+
|
|
12
|
+
**跨编队隔离:同 relay 上的另一账号机器不再出现在本编队的枚举/任务/聊天/记忆里。**
|
|
13
|
+
|
|
14
|
+
现场:另一账号的机器(自己的 fleet_id、自己的 fleet_key)出现在我们的机器列表里,被读成"非本账号
|
|
15
|
+
机器进入编队"。排查结论:该机器的编队绑定本身是正确的,问题在于 relay 的**读取面没有按 fleet 过滤**
|
|
16
|
+
(派发侧自 v0.7.7 起已按 fleet 隔离,operator token 为 relay 级权限属于设计)。
|
|
17
|
+
|
|
18
|
+
- `GET /v1/devices`:按请求设备的 fleet 过滤(无 fleet 的旧设备在未配置 fleet 的 relay 上保持历史行为)。
|
|
19
|
+
- `GET /v1/tasks`:任务记录携带 `fleet_id`,列表按调用者 fleet 过滤(operator 派发是 relay 级,不会进入
|
|
20
|
+
某个编队的列表)。
|
|
21
|
+
- `POST /v1/fleet/messages`:`fleet_id` 取自已认证设备,忽略 body(防止把消息标成别的编队)。
|
|
22
|
+
- `GET /v1/fleet/messages`:读取时按调用者 fleet 过滤。
|
|
23
|
+
- fleet memory 文档按 fleet 前缀隔离(无 fleet 设备沿用历史键,单编队 relay 不受影响)。
|
|
24
|
+
- 新增 `scripts/verify-fleet-isolation.mjs`:真实 relay + 两个 fleet 的黑盒验证(设备/任务/聊天/记忆
|
|
25
|
+
共 11 项检查),可作为部署前门禁脚本运行。
|
|
26
|
+
- 验证:脚本 11/11 通过(fleet task `01M5ANYMVL2Q8PD`);relay.test 200/200 通过。
|
|
10
27
|
## [1.5.4] — 2026-10-23
|
|
11
28
|
|
|
12
29
|
**验签 bootstrap 的结果覆盖过期的显式 relay(现场故障:设备仍指向 127.0.0.1)。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.5.5",
|
|
3
|
+
"case": "FLEET-ISOLATION",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T14:37:27.768Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "two fleets paired on one relay reach neither the other fleet device list, task list, fleet chat nor fleet memory",
|
|
11
|
+
"task_id": "01M5ANYMVL2Q8PD",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"verify_script": "11/11 checks passed",
|
|
16
|
+
"device_enumeration": "scoped",
|
|
17
|
+
"task_list": "scoped",
|
|
18
|
+
"fleet_chat": "scoped",
|
|
19
|
+
"fleet_memory": "scoped"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"note": "scripts/verify-fleet-isolation.mjs ran as a real fleet task against a real relay process"
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
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": {
|
|
@@ -162,7 +162,8 @@
|
|
|
162
162
|
"test:evidence": "node --test --test-force-exit test/evidence.test.mjs",
|
|
163
163
|
"test:grant-issue": "node --test --test-force-exit test/grant-issue.test.mjs",
|
|
164
164
|
"test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs",
|
|
165
|
-
"test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs"
|
|
165
|
+
"test:secondary-link": "node --test --test-force-exit test/secondary-link.test.mjs",
|
|
166
|
+
"verify:fleet-isolation": "node scripts/verify-fleet-isolation.mjs"
|
|
166
167
|
},
|
|
167
168
|
"devDependencies": {
|
|
168
169
|
"@eslint/js": "^10.0.1",
|
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": "1.5.
|
|
4
|
+
"version": "1.5.5",
|
|
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": [
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -277,7 +277,7 @@ const MAX_STAGES = 16;
|
|
|
277
277
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
278
278
|
* overrides it at runtime for tests and packagers.
|
|
279
279
|
*/
|
|
280
|
-
const PLUGIN_VERSION = '1.5.
|
|
280
|
+
const PLUGIN_VERSION = '1.5.5';
|
|
281
281
|
|
|
282
282
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
283
283
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
package/src/relay/server.mjs
CHANGED
|
@@ -1350,13 +1350,30 @@ export class RelayServer {
|
|
|
1350
1350
|
}
|
|
1351
1351
|
|
|
1352
1352
|
if (method === 'GET' && path === '/v1/devices') {
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1353
|
+
/**
|
|
1354
|
+
* v1.5.5: device enumeration is fleet-scoped.
|
|
1355
|
+
*
|
|
1356
|
+
* This endpoint used to return every device on the relay; state.mjs even recorded the narrowing
|
|
1357
|
+
* as a pending step. The observation that forced it: another account's machine (its own fleet id,
|
|
1358
|
+
* its own fleet key) appeared in this fleet's machine list -- correct fleet binding, wrong list.
|
|
1359
|
+
*
|
|
1360
|
+
* Migration rule, the same one task dispatch already obeys: a relay with no fleet configured
|
|
1361
|
+
* keeps the historical behaviour for fleetless devices, a scoped relay serves its own fleet, and
|
|
1362
|
+
* a fleet-bound device always sees exactly its own fleet and nothing else.
|
|
1363
|
+
*/
|
|
1364
|
+
const callerFleet = typeof device?.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
|
|
1365
|
+
const relayFleet = this.state.fleetId ?? null;
|
|
1366
|
+
const allDevices = this.state.listDevices();
|
|
1367
|
+
const devices = callerFleet === null
|
|
1368
|
+
? (relayFleet === null ? allDevices : allDevices.filter((entry) => entry.fleet_id === relayFleet))
|
|
1369
|
+
: allDevices.filter((entry) => entry.fleet_id === callerFleet);
|
|
1370
|
+
return sendJson(res, 200, {
|
|
1371
|
+
protocol_version: PROTOCOL_VERSION,
|
|
1372
|
+
rabbit_time: this.state.nowIso(),
|
|
1373
|
+
devices,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
if (method === 'GET' && path === '/v1/tasks') {
|
|
1360
1377
|
const rawLimit = url.searchParams.get('limit');
|
|
1361
1378
|
let limit = 50;
|
|
1362
1379
|
if (rawLimit !== null) {
|
|
@@ -1368,7 +1385,7 @@ export class RelayServer {
|
|
|
1368
1385
|
return sendJson(res, 200, {
|
|
1369
1386
|
protocol_version: PROTOCOL_VERSION,
|
|
1370
1387
|
rabbit_time: this.state.nowIso(),
|
|
1371
|
-
tasks: this.state.listTasks({ limit }),
|
|
1388
|
+
tasks: this.state.listTasks({ limit, fleetId: device.fleet_id ?? null }),
|
|
1372
1389
|
});
|
|
1373
1390
|
}
|
|
1374
1391
|
|
|
@@ -1532,7 +1549,7 @@ export class RelayServer {
|
|
|
1532
1549
|
const body = takeJson();
|
|
1533
1550
|
const posted = this.fleetMessages.post({
|
|
1534
1551
|
session_id: body.session_id,
|
|
1535
|
-
fleet_id:
|
|
1552
|
+
fleet_id: device.fleet_id ?? null,
|
|
1536
1553
|
machine_id: device.machine_id,
|
|
1537
1554
|
to: body.to,
|
|
1538
1555
|
thread_id: body.thread_id,
|
|
@@ -1599,7 +1616,10 @@ export class RelayServer {
|
|
|
1599
1616
|
limit: limitRaw === null ? 50 : Number(limitRaw),
|
|
1600
1617
|
// Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
|
|
1601
1618
|
// caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
|
|
1602
|
-
|
|
1619
|
+
// v1.5.5: a reader sees the chat of the fleet it belongs to; a device without a fleet keeps the
|
|
1620
|
+
// historical unqualified view.
|
|
1621
|
+
fleet_id: device.fleet_id ?? null,
|
|
1622
|
+
for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
|
|
1603
1623
|
thread_id: url.searchParams.get('thread_id'),
|
|
1604
1624
|
to: url.searchParams.get('to'),
|
|
1605
1625
|
});
|
|
@@ -1607,6 +1627,8 @@ export class RelayServer {
|
|
|
1607
1627
|
protocol_version: PROTOCOL_VERSION,
|
|
1608
1628
|
rabbit_time: this.state.nowIso(),
|
|
1609
1629
|
...listed,
|
|
1630
|
+
// v1.5.5: only the caller's fleet messages leave the process.
|
|
1631
|
+
messages: (listed.messages ?? []).filter((m) => (m.fleet_id ?? null) === (device.fleet_id ?? null)),
|
|
1610
1632
|
usage: this.fleetMessages.usage(), // shown with every read so a client can warn before the quota refuses a post
|
|
1611
1633
|
});
|
|
1612
1634
|
}
|
|
@@ -1618,14 +1640,16 @@ export class RelayServer {
|
|
|
1618
1640
|
if (path.startsWith('/v1/fleet/memory/')) {
|
|
1619
1641
|
const doc = decodeURIComponent(path.slice('/v1/fleet/memory/'.length));
|
|
1620
1642
|
if (doc === '') throw new ProtocolError('BAD_REQUEST', 'memory doc is required', { method, path });
|
|
1643
|
+
// v1.5.5: a fleet's memory must not be addressable by another fleet on the same relay.
|
|
1644
|
+
const docKey = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id + '::' + doc : doc;
|
|
1621
1645
|
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1622
1646
|
if (method === 'GET') {
|
|
1623
|
-
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(
|
|
1647
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(docKey) });
|
|
1624
1648
|
}
|
|
1625
1649
|
if (method === 'PUT') {
|
|
1626
1650
|
const body = takeJson();
|
|
1627
1651
|
const appended = this.fleetMemory.append({
|
|
1628
|
-
doc,
|
|
1652
|
+
doc: docKey,
|
|
1629
1653
|
if_revision: Number.isInteger(body.if_revision) ? body.if_revision : null,
|
|
1630
1654
|
author_machine_id: device.machine_id,
|
|
1631
1655
|
records: Array.isArray(body.records) ? body.records : (typeof body.text === 'string' ? [body.text] : []),
|
package/src/relay/state.mjs
CHANGED
|
@@ -1942,6 +1942,13 @@ export class RabbitState {
|
|
|
1942
1942
|
include_offline: input.include_offline === true,
|
|
1943
1943
|
halt,
|
|
1944
1944
|
created_by: input.created_by ?? null,
|
|
1945
|
+
// v1.5.5: the fleet a task belongs to, so list endpoints can scope by it.
|
|
1946
|
+
// v1.5.5: the fleet this task belongs to, computed from the same requester rule fleet scoping
|
|
1947
|
+
// uses. scopedRequester is declared further down (target selection), so it cannot be referenced
|
|
1948
|
+
// from the task literal; this expression reads the same sources and stays a pure lookup.
|
|
1949
|
+
fleet_id: ((typeof input.created_by === 'string' ? this.devices.get(input.created_by) : null) ||
|
|
1950
|
+
(typeof input.requester_machine_id === 'string' ? this.devices.get(input.requester_machine_id) : null) ||
|
|
1951
|
+
(this.fleetId !== null ? { fleet_id: this.fleetId } : null))?.fleet_id ?? null,
|
|
1945
1952
|
created_at_ms: now,
|
|
1946
1953
|
command_hash: commandHash,
|
|
1947
1954
|
attempt: 1,
|
|
@@ -2492,9 +2499,11 @@ export class RabbitState {
|
|
|
2492
2499
|
return { count: taskIds.length, task_ids: taskIds };
|
|
2493
2500
|
}
|
|
2494
2501
|
|
|
2495
|
-
listTasks({ limit = 50 } = {}) {
|
|
2502
|
+
listTasks({ limit = 50, fleetId = null } = {}) {
|
|
2496
2503
|
const n = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
|
2497
2504
|
return [...this.tasks.values()]
|
|
2505
|
+
// v1.5.5: a fleet-bound caller lists only its own fleet's tasks; null fleet keeps the old view.
|
|
2506
|
+
.filter((task) => fleetId === null || task.fleet_id === fleetId)
|
|
2498
2507
|
.sort((a, b) => b.created_at_ms - a.created_at_ms)
|
|
2499
2508
|
.slice(0, n)
|
|
2500
2509
|
.map((t) => this.taskSummary(t));
|