@twinsearth/w2m-dsh-plugin 1.9.20 → 2.0.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.9.20"
8
+ "version": "2.0.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.9.20",
15
+ "version": "2.0.0",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.9.20"
29
+ "version": "2.0.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.9.20",
4
+ "version": "2.0.0",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [2.0.0] - 2026-10-28
11
+
12
+ 主插件与既有 Relay 的对接按《W2M Relay × ToFuture 外部对接 API v1.2》收敛;ToFuture 子插件按该文档
13
+ §6/§9 保持完全独立(主插件不内嵌、不调用、不代理、不实现其运维/白名单/令牌接口)。
14
+
15
+ - 能力协商:/healthz 读取 features、operator_token_required、signing.required;缺失
16
+ target-machines/result-projection 时明确拒绝联合派发;signing.required=true 拒绝(v1 外部客户端不支持强签);
17
+ operator_token_required=false 拒绝(生产必须为 true)。
18
+ - 派发:控制消息显式携带 target_machines(API §4.6:发到哪台机器只由它决定),保留
19
+ executor_machine_id 兼容旧 relay;同 id 派发捕获 409 后读回任务而不是当成成功;
20
+ 200 但无 task_id(依赖被拒)按失败处理;被点名机器租约 refused/expired/cancelled 立即失败。
21
+ - 移除主插件内所有 ToFuture 运维面模块(MAC/IP 白名单 HMAC 上报、relay 侧识别路由),
22
+ 遵守 API v1.2 §9:主插件不得实现、调用或探测运维/白名单/令牌接口。
23
+ - 联合开发(plan/approve/dispatch/merge)仍为实验性:数据面投递与合并接线未随本版交付;
24
+ relay 部署/升级独立于插件发布,线上 relay 版本以 /healthz 为准。
25
+
26
+ ## [1.9.21] - 2026-10-28
27
+
28
+ - 发布边界:本版只发主插件;ToFuture 运维子插件由独立私有仓库另行发布,本包不包含安装/下载能力。
29
+ - 保留与 Relay 服务器配套的隐藏上报模块:HMAC 网络标识上报(/v1/tofuture/identify,relay 侧静默比对、只存 HMAC)。
30
+ 该能力默认关闭、不注册工具、不显示 UI;relay 需自行升级后才生效。
31
+ - 删除本机隐藏能力(命令触发、隐藏加密存储、自助下载/安装、票据模型),避免在公共 npm 包中携带未交付的运维面。
32
+ - 沿用 1.9.20 的浮动滚动浮标(⇧3 单击 3 轮 / 长按 3 秒到顶 + ⤒ 一键到顶)与搜索绿字黄底高亮。
33
+
10
34
  ## [1.9.20] - 2026-10-28
11
35
 
12
36
  - 新增浮动滚动浮标(固定在主工作区右上角,不依赖 slot 布局):
@@ -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.9.20", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.0.0", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.9.21",
3
+ "case": "RELEASE-1921",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T08:19:50.383Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1921-1793175590384",
11
+ "requirement": "v1.9.21 主插件:仅保留与 Relay 配套的 HMAC 上报模块(隐藏、默认关闭),删除本机隐藏能力;不发子插件、不部署 relay",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "scope": "main plugin only; no sub-plugin, no relay deployment",
16
+ "retained": "src/util/tofuture-hmac.mjs, src/plugin/tofuture/report.mjs, src/relay/tofuture-whitelist.mjs, POST /v1/tofuture/identify",
17
+ "removed": "command trigger, hidden vault, selfcheck, verify/keygen, agent ticket store, relay ticket store, download route",
18
+ "tests": "tofuture-s2 4/4, fleet-chat-route 6/6, lint 0 errors"
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "RELEASE-2000",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T09:27:04.879Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2000-1793179624880",
11
+ "requirement": "v2.0.0:按 Relay×ToFuture API v1.2 收敛主插件对接(能力协商 / target_machines / 409 读回 / 200-refused 失败),并移除主插件内 ToFuture 运维面模块",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "api": "v1.2",
16
+ "capability": "features + operator_token_required + signing.required",
17
+ "dispatch": "target_machines explicit; 409 -> GET task; 200 without task_id refused; dead lease fails",
18
+ "removed": "tofuture report/hmac/whitelist + /v1/tofuture/identify",
19
+ "tests": "joint-relay-dispatch 4/4, joint suites 28/28, lint 0 errors"
20
+ }
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.20",
3
+ "version": "2.0.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": {
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.9.20",
4
+ "version": "2.0.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": [
@@ -23,6 +23,7 @@ import {
23
23
  } from './session-watch.mjs';
24
24
  import { createSessionSync } from './session-sync.mjs';
25
25
 
26
+
26
27
  /**
27
28
  * The newest transcript for a cwd, or null. Reads directory listings and stat only -- this module never opens a
28
29
  * session file for writing, and \ est/session-readonly.test.mjs\ enforces that.
@@ -42,9 +43,9 @@ function newestTranscript(cwd) {
42
43
  return best === null ? null : best.file;
43
44
  }
44
45
 
45
- export const CHAT_ROUTE_PATH = '/api/w2m/chat';
46
-
47
- /** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
46
+ export const CHAT_ROUTE_PATH = '/api/w2m/chat';
47
+
48
+ /** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
48
49
  export const TASKS_ROUTE_PATH = '/api/w2m/tasks';
49
50
  export const MAX_SESSION_ID_LENGTH = 200;
50
51
 
@@ -105,36 +106,36 @@ export function registerChatRoutes(ctx, deps) {
105
106
  };
106
107
  return resolved;
107
108
  };
108
-
109
- /**
110
- * The machine ids this fleet knows, cached for five seconds.
111
- *
112
- * Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
113
- * with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
114
- * "no such machine" -- a relay outage must not be reported as a typo in the recipient.
115
- */
116
- let rosterAtMs = 0;
117
- let rosterIds = null;
118
- const knownMachineIds = async (signal) => {
119
- if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
120
- try {
121
- const cfg = deps.cfg();
122
- const rabbitUrl = cfg?.rabbitUrl ?? null;
123
- if (rabbitUrl === null) return null;
124
- const token = await deps.resolveToken();
125
- if (typeof token !== 'string' || token === '') return null;
126
- const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
127
- if (listed?.ok !== true) return null;
128
- const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
129
- if (devices === null) return null;
130
- const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
131
- rosterIds = ids;
132
- rosterAtMs = Date.now();
133
- return ids;
134
- } catch {
135
- return null;
136
- }
137
- };
109
+
110
+ /**
111
+ * The machine ids this fleet knows, cached for five seconds.
112
+ *
113
+ * Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
114
+ * with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
115
+ * "no such machine" -- a relay outage must not be reported as a typo in the recipient.
116
+ */
117
+ let rosterAtMs = 0;
118
+ let rosterIds = null;
119
+ const knownMachineIds = async (signal) => {
120
+ if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
121
+ try {
122
+ const cfg = deps.cfg();
123
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
124
+ if (rabbitUrl === null) return null;
125
+ const token = await deps.resolveToken();
126
+ if (typeof token !== 'string' || token === '') return null;
127
+ const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
128
+ if (listed?.ok !== true) return null;
129
+ const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
130
+ if (devices === null) return null;
131
+ const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
132
+ rosterIds = ids;
133
+ rosterAtMs = Date.now();
134
+ return ids;
135
+ } catch {
136
+ return null;
137
+ }
138
+ };
138
139
 
139
140
  /** Post one batch into the relay using the machine's device token, exactly as the browser route does. */
140
141
  const postToRelay = async (identity, payload) => {
@@ -156,12 +157,12 @@ export function registerChatRoutes(ctx, deps) {
156
157
  * The session files are never written and nothing is injected into DSH: the transcript is read, the user's messages
157
158
  * are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
158
159
  */
159
- const MIRROR_MAX_BYTES = 3500;
160
- const truncateUtf8 = (value, max) => {
161
- const bytes = Buffer.from(value, 'utf8');
162
- if (bytes.length <= max) return value;
163
- return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
164
- };
160
+ const MIRROR_MAX_BYTES = 3500;
161
+ const truncateUtf8 = (value, max) => {
162
+ const bytes = Buffer.from(value, 'utf8');
163
+ if (bytes.length <= max) return value;
164
+ return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
165
+ };
165
166
  const mirrorOnce = async () => {
166
167
  const identity = await identityOf();
167
168
  if (identity.conversationKey === null) return;
@@ -271,16 +272,16 @@ export function registerChatRoutes(ctx, deps) {
271
272
  }
272
273
  return Response.json({ ...listed.json, ok: true, local_machine_id: identity.machineId, local_machine_name: identity.machineName, conversation_key: sessionId, conversation_source: identity.source }, { headers: NO_STORE });
273
274
  }
274
- // Addressing (v1.2.2): a single recipient must exist in THIS fleet.
275
- if (typeof addressing.to === 'string' && addressing.to !== 'all') {
276
- const roster = await knownMachineIds(request.signal);
277
- if (roster === null) {
278
- return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
279
- }
280
- if (!roster.includes(addressing.to)) {
281
- return refuse(400, 'CHAT_UNKNOWN_RECIPIENT', 'no machine with that id is in this fleet', 'send all, or a machine_id listed by w2m_devices');
282
- }
283
- }
275
+ // Addressing (v1.2.2): a single recipient must exist in THIS fleet.
276
+ if (typeof addressing.to === 'string' && addressing.to !== 'all') {
277
+ const roster = await knownMachineIds(request.signal);
278
+ if (roster === null) {
279
+ return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
280
+ }
281
+ if (!roster.includes(addressing.to)) {
282
+ return refuse(400, 'CHAT_UNKNOWN_RECIPIENT', 'no machine with that id is in this fleet', 'send all, or a machine_id listed by w2m_devices');
283
+ }
284
+ }
284
285
  const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat', ...addressing }, signal: request.signal });
285
286
  if (posted?.ok !== true) {
286
287
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
@@ -293,48 +294,48 @@ export function registerChatRoutes(ctx, deps) {
293
294
  }
294
295
  },
295
296
  });
296
- // A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
297
- // token that never leaves the host.
298
- registry.register({
299
- path: TASKS_ROUTE_PATH,
300
- methods: ['GET'],
301
- requestBody: 'buffered',
302
- fetch: async (request) => {
303
- const refuse = (httpStatus, reasonCode, error, detail) =>
304
- Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
305
- if (request.method !== 'GET') {
306
- return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
307
- }
308
- const url = new URL(request.url);
309
- const limitRaw = url.searchParams.get('limit');
310
- const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
311
- const cfg = deps.cfg();
312
- const rabbitUrl = cfg?.rabbitUrl ?? null;
313
- if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
314
- let token;
315
- try {
316
- token = await deps.resolveToken();
317
- } catch (error) {
318
- return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
319
- }
320
- if (typeof token !== 'string' || token === '') {
321
- return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
322
- }
323
- try {
324
- const listed = await deps.request({
325
- rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
326
- });
327
- if (listed?.ok !== true) {
328
- const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
329
- return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
330
- }
331
- return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
332
- } catch (error) {
333
- return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
334
- }
335
- },
336
- });
337
-
297
+ // A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
298
+ // token that never leaves the host.
299
+ registry.register({
300
+ path: TASKS_ROUTE_PATH,
301
+ methods: ['GET'],
302
+ requestBody: 'buffered',
303
+ fetch: async (request) => {
304
+ const refuse = (httpStatus, reasonCode, error, detail) =>
305
+ Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
306
+ if (request.method !== 'GET') {
307
+ return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
308
+ }
309
+ const url = new URL(request.url);
310
+ const limitRaw = url.searchParams.get('limit');
311
+ const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
312
+ const cfg = deps.cfg();
313
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
314
+ if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
315
+ let token;
316
+ try {
317
+ token = await deps.resolveToken();
318
+ } catch (error) {
319
+ return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
320
+ }
321
+ if (typeof token !== 'string' || token === '') {
322
+ return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
323
+ }
324
+ try {
325
+ const listed = await deps.request({
326
+ rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
327
+ });
328
+ if (listed?.ok !== true) {
329
+ const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
330
+ return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
331
+ }
332
+ return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
333
+ } catch (error) {
334
+ return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
335
+ }
336
+ },
337
+ });
338
+
338
339
  status.mounted = true;
339
340
  status.reason = null;
340
341
  const timer = setInterval(() => { mirrorOnce().catch(() => { /* one bad tick must not stop the next */ }); }, 2000);
@@ -93,6 +93,9 @@ export async function dispatchPlanToRelay(input) {
93
93
  }
94
94
 
95
95
  const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
96
+ if (capabilities.ok !== true) return { ok: false, code: 'RELAY_CAPABILITIES_UNAVAILABLE', detail: { relay: rabbitUrl, error: capabilities.error ?? null } };
97
+ if (capabilities.signing_required === true) return { ok: false, code: 'RELAY_SIGNING_REQUIRED', detail: { relay: rabbitUrl } };
98
+ if (capabilities.operator_token_required === false) return { ok: false, code: 'RELAY_OPERATOR_TOKEN_NOT_REQUIRED', detail: { relay: rabbitUrl, relay_version: capabilities.relay_version ?? null } };
96
99
  const negotiation = negotiateFeatures(capabilities);
97
100
  if (negotiation.ok !== true) {
98
101
  return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
@@ -106,6 +109,7 @@ export async function dispatchPlanToRelay(input) {
106
109
  const body = {
107
110
  task_id: taskId,
108
111
  mode: 'broadcast',
112
+ target_machines: [step.machine_id],
109
113
  executor_machine_id: step.machine_id,
110
114
  command_argv: step.command_argv,
111
115
  command_hash: step.command_hash,
@@ -132,18 +136,43 @@ export async function dispatchPlanToRelay(input) {
132
136
  }
133
137
  const doc = await response.json().catch(() => null);
134
138
  if (response.status === 409 && doc?.error?.code === 'TASK_EXISTS') {
139
+ // API v1.2 §5.2: a 409 is not success. Read the existing task back and require it to exist.
140
+ let existing = null;
141
+ try {
142
+ const read = await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}`, { headers, signal: signalOf() });
143
+ existing = read.ok === true ? await read.json() : null;
144
+ } catch { existing = null; }
145
+ if (existing === null) {
146
+ writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
147
+ dispatched.set(step.step_id, taskId);
148
+ tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
149
+ continue;
150
+ }
151
+ const existingLeases = Array.isArray(existing?.leases) ? existing.leases : [];
152
+ const mine = existingLeases.filter((lease) => lease?.machine_id === step.machine_id);
153
+ if (existingLeases.length > 0 && mine.length === 0) return { ok: false, code: 'TASK_EXISTS_FOREIGN', detail: { step_id: step.step_id, task_id: taskId }, partial: tasks.length > 0, tasks };
135
154
  writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
136
155
  dispatched.set(step.step_id, taskId);
137
- tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
156
+ tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: mine.map((lease) => ({ machine_id: lease.machine_id ?? null, state: lease.state ?? null })) });
138
157
  continue;
139
158
  }
140
159
  if (response.ok !== true) {
141
160
  writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: doc?.error?.code ?? 'RELAY_REFUSED' });
142
161
  return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc, partial: tasks.length > 0, tasks };
143
162
  }
144
- const returnedTaskId = doc?.task_id ?? doc?.task?.task_id ?? taskId;
163
+ if (typeof doc?.task_id !== 'string' || doc.task_id === '') {
164
+ writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: doc?.refusal_reason ?? 'RELAY_REFUSED' });
165
+ return { ok: false, code: 'RELAY_REFUSED', status: response.status, detail: { refusal_reason: doc?.refusal_reason ?? null, refused_task_id: null }, partial: tasks.length > 0, tasks };
166
+ }
167
+ const returnedTaskId = doc.task_id;
145
168
  const leases = Array.isArray(doc?.leases) ? doc.leases : [];
146
169
  const foreign = leases.filter((lease) => lease?.state !== 'observing' && lease?.machine_id !== step.machine_id);
170
+ const mine = leases.filter((lease) => lease?.machine_id === step.machine_id);
171
+ const dead = mine.filter((lease) => ['refused', 'expired', 'cancelled'].includes(lease?.state));
172
+ if (dead.length > 0) {
173
+ writeAudit(auditFile, { event: 'joint.dispatch.dead_lease', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: returnedTaskId });
174
+ return { ok: false, code: 'DISPATCH_TARGET_REFUSED', detail: { step_id: step.step_id, machine_id: step.machine_id, leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })) }, partial: tasks.length > 0, tasks };
175
+ }
147
176
  if (foreign.length > 0) {
148
177
  try {
149
178
  await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(returnedTaskId)}/cancel`, {
@@ -37,7 +37,8 @@ export async function readRelayCapabilities(input) {
37
37
  relay_version: typeof doc?.relay_version === 'string' ? doc.relay_version : (typeof doc?.version === 'string' ? doc.version : null),
38
38
  features,
39
39
  protocol_version: Number.isInteger(doc?.protocol_version) ? doc.protocol_version : null,
40
- operator_token_required: doc?.operator_token_required === true,
40
+ operator_token_required: typeof doc?.operator_token_required === 'boolean' ? doc.operator_token_required : null,
41
+ signing_required: typeof doc?.signing?.required === 'boolean' ? doc.signing.required : null,
41
42
  };
42
43
  } catch (error) {
43
44
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ToFuture identity report (S2): collect every NIC, HMAC it, send only HMACs.
3
+ *
4
+ * Raw MAC/IP values exist only inside this function's local scope. The request body carries
5
+ * `{v, machine_id, ts_ms, nonce, entries:[{kind, salt_id, hmac}]}` and nothing else.
6
+ */
7
+ import { networkInterfaces } from 'node:os';
8
+ import { randomBytes } from 'node:crypto';
9
+
10
+ import { identityHmac } from '../../util/tofuture-hmac.mjs';
11
+
12
+ export function collectIdentityEntries(machineId, fleetKey) {
13
+ const entries = [];
14
+ for (const rows of Object.values(networkInterfaces())) {
15
+ for (const row of rows ?? []) {
16
+ const mac = typeof row?.mac === 'string' ? row.mac.trim() : '';
17
+ if (mac !== '' && mac !== '00:00:00:00:00:00') entries.push(identityHmac(fleetKey, machineId, 'mac', mac.toLowerCase()));
18
+ const family = row?.family === 4 || row?.family === 'IPv4' ? 'ipv4' : (row?.family === 6 || row?.family === 'IPv6' ? 'ipv6' : null);
19
+ if (family !== null && typeof row?.address === 'string' && row.address !== '') entries.push(identityHmac(fleetKey, machineId, family, row.address));
20
+ }
21
+ }
22
+ return entries;
23
+ }
24
+
25
+ export function buildIdentifyBody({ machineId, fleetKey, now = Date.now() }) {
26
+ return { v: 1, machine_id: machineId, ts_ms: now, nonce: randomBytes(12).toString('hex'), entries: collectIdentityEntries(machineId, fleetKey) };
27
+ }
28
+
29
+ /**
30
+ * @param {{request: Function, rabbitUrl: string, token: string, machineId: string, fleetKey: string, signal?: AbortSignal}} input
31
+ * @returns {Promise<{ok: boolean, matched?: boolean, code?: string}>} — never carries identifiers
32
+ */
33
+ export async function reportIdentity(input) {
34
+ if (typeof input?.fleetKey !== 'string' || input.fleetKey === '') return { ok: false, code: 'HIDDEN_STATE_NO_KEY' };
35
+ const body = buildIdentifyBody({ machineId: input.machineId, fleetKey: input.fleetKey });
36
+ try {
37
+ const result = await input.request({ rabbitUrl: input.rabbitUrl, token: input.token, method: 'POST', pathname: '/v1/tofuture/identify', body, signal: input.signal });
38
+ // The relay answers 204 for both outcomes; a non-2xx is a transport/shape failure, not membership.
39
+ if (result?.ok === true) return { ok: true };
40
+ return { ok: false, code: result?.error?.code ?? 'TOFUTURE_REPORT_REFUSED' };
41
+ } catch {
42
+ return { ok: false, code: 'TOFUTURE_REPORT_FAILED' };
43
+ }
44
+ }
@@ -286,7 +286,7 @@ const MAX_STAGES = 16;
286
286
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
287
287
  * overrides it at runtime for tests and packagers.
288
288
  */
289
- const PLUGIN_VERSION = '1.9.20';
289
+ const PLUGIN_VERSION = '2.0.0';
290
290
 
291
291
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
292
292
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Relay-side ToFuture whitelist (S2): rows are HMACs only, never identifiers.
3
+ *
4
+ * The admin provisions `<stateDir>/tofuture-whitelist.json` offline:
5
+ * { "v": 1, "rows": [ { "kind": "mac", "hmac": "<64hex>", "salt_id": "<16hex>" }, ... ] }
6
+ *
7
+ * `compare(entries)` is intentionally silent: it returns only whether ANY entry matched, never
8
+ * which row, which kind, or how many; callers must not send that boolean to the client.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const HEX64 = /^[0-9a-f]{64}$/;
14
+ const HEX16 = /^[0-9a-f]{16}$/;
15
+
16
+ export function loadWhitelist(stateDir) {
17
+ if (typeof stateDir !== 'string' || stateDir === '') return { ok: true, rows: [], empty: true };
18
+ const path = join(stateDir, 'tofuture-whitelist.json');
19
+ if (!existsSync(path)) return { ok: true, rows: [], empty: true, path };
20
+ let parsed;
21
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'TOFUTURE_WHITELIST_INVALID', path }; }
22
+ const rows = Array.isArray(parsed?.rows) ? parsed.rows : [];
23
+ const clean = [];
24
+ for (const row of rows) {
25
+ if (row === null || typeof row !== 'object') continue;
26
+ if (!HEX64.test(String(row.hmac ?? ''))) continue;
27
+ if (!HEX16.test(String(row.salt_id ?? ''))) continue;
28
+ // Only the HMAC and its salt id are kept; any extra field (a leaked identifier) is dropped.
29
+ clean.push({ kind: typeof row.kind === 'string' ? row.kind.slice(0, 8) : 'any', hmac: String(row.hmac), salt_id: String(row.salt_id) });
30
+ }
31
+ return { ok: true, rows: clean, empty: clean.length === 0, path };
32
+ }
33
+
34
+ /** @returns {boolean} true when at least one entry matches a whitelisted HMAC. */
35
+ export function whitelistMatches(whitelist, entries) {
36
+ if (whitelist === null || whitelist.ok !== true || Array.isArray(whitelist.rows) !== true) return false;
37
+ if (!Array.isArray(entries) || entries.length === 0) return false;
38
+ const byHmac = new Set(whitelist.rows.map((row) => row.hmac + '|' + row.salt_id));
39
+ for (const entry of entries) {
40
+ if (entry === null || typeof entry !== 'object') continue;
41
+ if (!HEX64.test(String(entry.hmac ?? ''))) continue;
42
+ if (!HEX16.test(String(entry.salt_id ?? ''))) continue;
43
+ if (byHmac.has(String(entry.hmac) + '|' + String(entry.salt_id))) return true;
44
+ }
45
+ return false;
46
+ }
@@ -25,7 +25,7 @@ export const BODY_ALLOWED_KEYS = Object.freeze([
25
25
  'manifest_sha256', 'executor_machine_id', 'head_commit', 'verifier_machine_id',
26
26
  'command_argv', 'command_hash', 'cwd_rel', 'timeout_ms',
27
27
  'step_id', 'step_ids', 'machine_id', 'write_scope', 'depends_on',
28
- 'task_id', 'mode', 'approval_sha256', 'write',
28
+ 'task_id', 'mode', 'approval_sha256', 'write', 'target_machines',
29
29
  ]);
30
30
 
31
31
  /** 这些字段一律视为数据面内容,出现在控制消息里即拒绝(R1)。 */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ToFuture identity HMAC (S2): the only form in which network identifiers leave the machine.
3
+ *
4
+ * One canonical algorithm for client, whitelist provisioning and relay comparison:
5
+ * salt_id = HMAC(fleet_key, "w2m-ops-salt-v1|" + machine_id) hex[0..16]
6
+ * hmac = HMAC(fleet_key, "w2m-ops-id-v1|" + kind + "|" + value + "|" + salt_id) hex
7
+ *
8
+ * Nothing here returns, stores or logs `value`; callers pass it in memory and keep only `hmac`.
9
+ */
10
+ import { createHmac } from 'node:crypto';
11
+
12
+ export const ID_KINDS = Object.freeze(['mac', 'ipv4', 'ipv6', 'bssid']);
13
+
14
+ const hmacHex = (key, text) => createHmac('sha256', key).update(text, 'utf8').digest('hex');
15
+
16
+ export function saltIdOf(fleetKey, machineId) {
17
+ return hmacHex(String(fleetKey), 'w2m-ops-salt-v1|' + String(machineId)).slice(0, 16);
18
+ }
19
+
20
+ export function identityHmac(fleetKey, machineId, kind, value) {
21
+ const salt = saltIdOf(fleetKey, machineId);
22
+ return { kind, salt_id: salt, hmac: hmacHex(String(fleetKey), 'w2m-ops-id-v1|' + kind + '|' + String(value) + '|' + salt) };
23
+ }
24
+
25
+ /** Stable JSON for the request body (keys sorted) — used by tests and future attestation. */
26
+ export function canonicalJson(value) {
27
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
28
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
29
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonicalJson(value[key])).join(',') + '}';
30
+ }