@twinsearth/w2m-dsh-plugin 1.5.4 → 1.6.0

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.
@@ -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.4"
8
+ "version": "1.6.0"
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.4",
15
+ "version": "1.6.0",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.5.4"
29
+ "version": "1.6.0"
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",
4
+ "version": "1.6.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.6.0] — 2026-10-24
11
+
12
+ **一台 relay 承载 N 个编队:每个 DeepSeek 账号一个编队,共享 relay 但互相隔离。**
13
+
14
+ - bootstrap 答案从"单个编队的归属"改为"relay 拓扑声明":签名与有效期仍严格,fleet_id 不匹配不再拒绝
15
+ (`strictFleet: true` 保留旧行为)。一份 operator 签名即可服务全部编队,新账号机器无需人工重签。
16
+ - `scripts/verify-fleet-isolation.mjs` 扩展到 **3 个编队** 14 项检查(设备枚举/任务/聊天/记忆),全过。
17
+ - 新增 `docs/MULTI-FLEET.md`:编排模型、运维视图、零人工接入新账号、配额与边界。
18
+ - 测试:bootstrap 6/6(含拓扑答案用例)、fleet isolation 14/14;证据 `docs/acceptance/1.6.0/N-FLEET.json`
19
+ (fleet task `01M5ANYMVLBU716`)。
20
+ ## [1.5.5] — 2026-10-23
21
+
22
+ **跨编队隔离:同 relay 上的另一账号机器不再出现在本编队的枚举/任务/聊天/记忆里。**
23
+
24
+ 现场:另一账号的机器(自己的 fleet_id、自己的 fleet_key)出现在我们的机器列表里,被读成"非本账号
25
+ 机器进入编队"。排查结论:该机器的编队绑定本身是正确的,问题在于 relay 的**读取面没有按 fleet 过滤**
26
+ (派发侧自 v0.7.7 起已按 fleet 隔离,operator token 为 relay 级权限属于设计)。
27
+
28
+ - `GET /v1/devices`:按请求设备的 fleet 过滤(无 fleet 的旧设备在未配置 fleet 的 relay 上保持历史行为)。
29
+ - `GET /v1/tasks`:任务记录携带 `fleet_id`,列表按调用者 fleet 过滤(operator 派发是 relay 级,不会进入
30
+ 某个编队的列表)。
31
+ - `POST /v1/fleet/messages`:`fleet_id` 取自已认证设备,忽略 body(防止把消息标成别的编队)。
32
+ - `GET /v1/fleet/messages`:读取时按调用者 fleet 过滤。
33
+ - fleet memory 文档按 fleet 前缀隔离(无 fleet 设备沿用历史键,单编队 relay 不受影响)。
34
+ - 新增 `scripts/verify-fleet-isolation.mjs`:真实 relay + 两个 fleet 的黑盒验证(设备/任务/聊天/记忆
35
+ 共 11 项检查),可作为部署前门禁脚本运行。
36
+ - 验证:脚本 11/11 通过(fleet task `01M5ANYMVL2Q8PD`);relay.test 200/200 通过。
10
37
  ## [1.5.4] — 2026-10-23
11
38
 
12
39
  **验签 bootstrap 的结果覆盖过期的显式 relay(现场故障:设备仍指向 127.0.0.1)。**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.5.4", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.6.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,38 @@
1
+ # 多编队(N Fleets):每个 DeepSeek 账号一个编队
2
+
3
+ 一台 relay 可以同时承载多个编队。编队身份**不需要注册**:每个账号派生自己的 fleet key,
4
+ `sha256(fleet_key)` 就是它的 fleet id;任意机器配对时携带自己的 fleet key,relay 就把它记进对应编队。
5
+
6
+ ## 一、编排模型
7
+
8
+ ```
9
+ DeepSeek 账号 A ── fleet_key_A ── sha256 ──► fleet_id_A ──┐
10
+ DeepSeek 账号 B ── fleet_key_B ── sha256 ──► fleet_id_B ──┼── 同一台 relay(共享基础设施)
11
+ DeepSeek 账号 C ── fleet_key_C ── sha256 ──► fleet_id_C ──┘
12
+ ```
13
+
14
+ - **每账号一个编队**:fleet_id 不同即不同编队,机器之间在设备列表、任务、聊天、共享记忆四个面互相不可见(v1.5.5 隔离)。
15
+ - **relay 无状态注册**:不需要预创建编队、不需要分配名额;第一台机器配对即产生该编队。
16
+ - **operator 只签一次**:bootstrap 签名描述的是 relay 拓扑(哪些 relay 节点),一份签名服务全部编队(v1.6.0)。
17
+ 需要把某台机器强制绑定到指定 fleet 时,用 `strictFleet: true` 保留旧行为。
18
+
19
+ ## 二、运维视图
20
+
21
+ - 查看编队成员:`GET /v1/devices`(按调用者编队过滤);relay 侧的 `devices.json` 里每个设备带 `fleet_id` / `fleet_source`。
22
+ - 编队聊天:`/v1/fleet/messages`,POST 的 fleet 取自已认证设备,GET 只返回本编队消息。
23
+ - 共享记忆:`/v1/fleet/memory/<doc>`,文档按 `fleet_id` 前缀隔离存储。
24
+ - 任务:记录携带 `fleet_id`,`GET /v1/tasks` 只列本编队;operator token 的派发是 relay 级权限。
25
+ - 配额:relay 级限制(任务/结果/配对码等)按 relay 计数;每编队建议自行控制设备数量(例如 ≤50),
26
+ 以便故障时 lease 与任务表保持可读。
27
+
28
+ ## 三、接入新账号(零人工)
29
+
30
+ 1. 机器安装插件 **1.6.0+**(内置 bootstrap 端点 + operator 公钥);
31
+ 2. 该机器登录对应 DeepSeek 账号 → 账号派生 fleet key → 自动配对到自己的编队;
32
+ 3. `w2m_status` 的 `bootstrap` 字段会显示 `source`、`relays`、`fleet_mismatch`(拓扑答案跨编队时)与 `last_error`。
33
+
34
+ ## 四、边界与注意
35
+
36
+ - 跨编队不共享任何**读写面**;共享的只有 relay 进程、磁盘与网络带宽。
37
+ - 运维账号(operator token)是 relay 级凭据,可跨编队派发任务/管理 grant——它不等同于任一编队成员。
38
+ - 清理编队:删除该编队的设备记录即可(设备 token 失效后需重新配对);relay 不保留编队级历史数据。
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.6.0",
3
+ "case": "N-FLEET",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T18:52:24.400Z",
9
+ "executor": "lead",
10
+ "requirement": "one relay hosts N fleets (one per DeepSeek account) isolated from each other, and one operator-signed bootstrap answer serves all of them",
11
+ "task_id": "01M5ANYMVLBU716",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "verify_script": "14/14 checks over 3 fleets",
16
+ "bootstrap_suite": "6/6",
17
+ "topology_answer": "fleet mismatch tolerated unless strictFleet"
18
+ }
19
+ },
20
+ "note": "real relay process, three fleets paired with three fleet keys"
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.5.4",
3
+ "version": "1.6.0",
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",
4
+ "version": "1.6.0",
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": [
@@ -155,8 +155,18 @@ export function verifyBootstrapAnswer(answer, input) {
155
155
  // v1.5.3: when the machine cannot derive a fleet key yet, it still must not be stranded: the
156
156
  // signed relay list is public routing information, so a signature-only answer is adopted for relay
157
157
  // assignment. Joining the fleet still needs the account-derived key, which is a separate step.
158
- if (input.fleetId !== null && input.fleetId !== undefined && (typeof answer.fleet_id !== 'string' || answer.fleet_id !== input.fleetId)) {
159
- return refuse(BOOTSTRAP_REFUSAL.WRONG_FLEET, 'the answer names a different fleet; it is not for this machine');
158
+ /**
159
+ * v1.6.0: a signed answer states relay topology, not one fleet's identity.
160
+ *
161
+ * Every DeepSeek account derives its own fleet key and therefore its own fleet id; on a shared
162
+ * relay those fleets coexist and are isolated per fleet (v1.5.5). The relay list is the same for
163
+ * all of them, so one operator-signed answer can serve N fleets -- refusing it for a fleet id
164
+ * mismatch would require the operator to re-sign per account, which is exactly the manual step
165
+ * this release removes. The signature and expiry remain strict; only the fleet binding relaxes.
166
+ */
167
+ const fleetMismatch = typeof input.fleetId === 'string' && answer.fleet_id !== input.fleetId;
168
+ if (fleetMismatch && input.strictFleet === true) {
169
+ return refuse(BOOTSTRAP_REFUSAL.WRONG_FLEET, 'the answer names a different fleet and strictFleet is set');
160
170
  }
161
171
  const relays = Array.isArray(answer.relays)
162
172
  ? [...new Set(answer.relays.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, '')))]
@@ -194,7 +204,7 @@ export function verifyBootstrapAnswer(answer, input) {
194
204
  if (!verifiedSignatureOk(signatureOk)) {
195
205
  return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer signature does not match the pinned operator key and the canonical payload');
196
206
  }
197
- return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at };
207
+ return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at, answer_fleet_id: typeof answer.fleet_id === 'string' ? answer.fleet_id : null, fleet_mismatch: fleetMismatch };
198
208
  }
199
209
 
200
210
  /** Kept separate so a test can assert the failure branch without a real crypto fault. */
@@ -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.4';
280
+ const PLUGIN_VERSION = '1.6.0';
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;
@@ -1350,13 +1350,30 @@ export class RelayServer {
1350
1350
  }
1351
1351
 
1352
1352
  if (method === 'GET' && path === '/v1/devices') {
1353
- return sendJson(res, 200, {
1354
- protocol_version: PROTOCOL_VERSION,
1355
- rabbit_time: this.state.nowIso(),
1356
- devices: this.state.listDevices(),
1357
- });
1358
- }
1359
- if (method === 'GET' && path === '/v1/tasks') {
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: typeof body.fleet_id === 'string' ? body.fleet_id : null,
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
- for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
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(doc) });
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] : []),
@@ -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));