@twinsearth/w2m-dsh-plugin 1.9.14 → 1.9.16

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.14"
8
+ "version": "1.9.16"
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.14",
15
+ "version": "1.9.16",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.9.14"
29
+ "version": "1.9.16"
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.14",
4
+ "version": "1.9.16",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.9.16] - 2026-10-28
11
+
12
+ - G0-2:relay /healthz 新增 relay_version 与 features(target-machines / result-projection / guard-v1 / relay-version-v1);w2m_status 显示这两项,便于核对三台 relay 的实际版本与能力。
13
+ - 新增 relay-capabilities 模块与 w2m-joint dispatch 能力协商:目标 relay 缺少必需 feature(或旧 relay 完全不广告)时,联合派发 fail-closed 返回 RELAY_CAPABILITIES_MISSING,不靠“先发再发现”。
14
+ - 新增 test/relay-health-capabilities.test.mjs(5 项)。
15
+
16
+ ## [1.9.15] - 2026-10-28
17
+
18
+ - G0-1:operator token 安全存储。新增 `~/.w2m/operator-token`(0600,可用 W2M_OPERATOR_TOKEN_FILE 覆盖)与 `w2m-operator` CLI(set 从 stdin/文件读取、status 只显示指纹、clear);w2m_status 新增 operatorToken_fingerprint/operatorToken_source,token 值不进日志/状态。
19
+ - 解析顺序:profile config(兼容)→ 安全文件(推荐)→ 环境变量(兼容)。
20
+ - 新增 test/operator-token.test.mjs(6 项)。
21
+
10
22
  ## [1.9.14] - 2026-10-28
11
23
 
12
24
  - 修复「立即更新」失败(fetch failed):GitHub Release 下载被网络阻断时,自动回退到 npm registry 的同一版本 tarball;下载前校验 registry 的 sha512 integrity(缺失时 sha1 shasum),再由现有安装器做 sha256 校验、备份与回滚。
package/bin/w2m-joint.mjs CHANGED
@@ -20,6 +20,7 @@
20
20
  import { readFileSync } from 'node:fs';
21
21
 
22
22
  import { buildControlEnvelopes, buildPlan } from '../src/plugin/joint-dev/dispatch.mjs';
23
+ import { negotiateFeatures, readRelayCapabilities } from '../src/plugin/relay-capabilities.mjs';
23
24
  import {
24
25
  approvePlan,
25
26
  assertDispatchAllowed,
@@ -135,10 +136,22 @@ if (command === 'dispatch') {
135
136
  if (args['dry-run'] !== true) {
136
137
  fail('DISPATCH_NOT_WIRED', 'real dispatch is the next slice: it needs the operator token and a relay with target_machines/capability advertisement; use --dry-run to inspect the envelopes');
137
138
  }
139
+ let capabilities = null;
140
+ let negotiation = null;
141
+ if (typeof args.relay === 'string') {
142
+ capabilities = await readRelayCapabilities({ rabbitUrl: args.relay });
143
+ negotiation = negotiateFeatures(capabilities);
144
+ if (negotiation.ok !== true) {
145
+ fail(negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', { missing: negotiation.missing ?? null, relay: args.relay });
146
+ }
147
+ }
138
148
  const envelopes = buildControlEnvelopes(gate.plan);
139
149
  print({
140
150
  ok: true,
141
151
  dry_run: true,
152
+ relay: typeof args.relay === 'string' ? args.relay : null,
153
+ relay_capabilities: capabilities === null ? null : { relay_version: capabilities.relay_version, features: capabilities.features },
154
+ relay_negotiation: negotiation,
142
155
  plan_sha256: gate.plan.plan_sha256,
143
156
  approval_sha256: gate.approval.approval_sha256,
144
157
  envelopes: envelopes.length,
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Operator token CLI (v2 G0-1).
4
+ *
5
+ * The token is read from stdin, never from argv, and stored in a 0600 file. Output is JSON with a
6
+ * fingerprint and the file path only — the value is never printed back.
7
+ *
8
+ * Usage:
9
+ * echo <token> | node bin/w2m-operator.mjs set
10
+ * node bin/w2m-operator.mjs set --file <path> (reads the file, still never echoes it)
11
+ * node bin/w2m-operator.mjs status
12
+ * node bin/w2m-operator.mjs clear
13
+ *
14
+ * Exit codes: 0 ok, 1 refusal, 2 usage.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ import {
19
+ clearOperatorToken,
20
+ fingerprintOf,
21
+ operatorTokenPath,
22
+ readOperatorToken,
23
+ writeOperatorToken,
24
+ } from '../src/plugin/operator-token.mjs';
25
+
26
+ const args = process.argv.slice(2);
27
+ const command = args[0];
28
+ const flag = (name) => {
29
+ const index = args.indexOf(name);
30
+ return index >= 0 ? args[index + 1] : undefined;
31
+ };
32
+ const print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
33
+
34
+ async function readStdin() {
35
+ const chunks = [];
36
+ for await (const chunk of process.stdin) chunks.push(chunk);
37
+ return Buffer.concat(chunks).toString('utf8');
38
+ }
39
+
40
+ if (command === 'set') {
41
+ const file = flag('--file');
42
+ let token;
43
+ try { token = file !== undefined ? readFileSync(file, 'utf8') : await readStdin(); } catch (error) {
44
+ print({ ok: false, code: 'TOKEN_READ_FAILED', error: error instanceof Error ? error.message : String(error) });
45
+ process.exit(1);
46
+ }
47
+ const written = writeOperatorToken(token);
48
+ if (written.ok !== true) {
49
+ print({ ok: false, code: written.code, detail: written.detail ?? null });
50
+ process.exit(1);
51
+ }
52
+ print({ ok: true, path: written.path, fingerprint: written.fingerprint, source: 'file' });
53
+ process.exit(0);
54
+ }
55
+
56
+ if (command === 'status') {
57
+ const read = readOperatorToken();
58
+ if (read.configured !== true) {
59
+ print({ ok: true, configured: false, path: read.path, error: read.error ?? null });
60
+ process.exit(0);
61
+ }
62
+ print({ ok: true, configured: true, path: read.path, fingerprint: fingerprintOf(read.token) });
63
+ process.exit(0);
64
+ }
65
+
66
+ if (command === 'clear') {
67
+ const cleared = clearOperatorToken();
68
+ print({ ok: cleared.ok === true, path: cleared.path, code: cleared.code ?? null });
69
+ process.exit(cleared.ok === true ? 0 : 1);
70
+ }
71
+
72
+ print({ ok: false, code: 'USAGE', path: operatorTokenPath(), usage: 'set [--file <path>] | status | clear' });
73
+ process.exit(2);
@@ -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.14", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.16", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.9.15",
3
+ "case": "RELEASE-1915",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T01:02:47.935Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1915-1793149367936",
11
+ "requirement": "V1.9.15:G0-1 operator token 安全存储与指纹状态",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "storage": "~/.w2m/operator-token 0600 or W2M_OPERATOR_TOKEN_FILE",
16
+ "cli": "set/status/clear, stdin only, fingerprint only",
17
+ "order": "config -> file -> env",
18
+ "status": "operatorToken_fingerprint/operatorToken_source, never value",
19
+ "tests": "operator-token 6/6"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.9.16",
3
+ "case": "RELEASE-1916",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T01:20:53.202Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1916-1793150453204",
11
+ "requirement": "V1.9.16:relay 版本/能力广告与联合派发能力协商 fail-closed",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "healthz": "relay_version + features[]",
16
+ "status": "w2m_status exposes relay_version/features",
17
+ "negotiation": "joint dispatch refuses RELAY_CAPABILITIES_MISSING on old relay or missing feature",
18
+ "tests": "relay-health-capabilities 5/5"
19
+ }
20
+ }
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.14",
3
+ "version": "1.9.16",
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": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -207,7 +207,8 @@
207
207
  "w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs",
208
208
  "w2m-hidden-task": "bin/w2m-hidden-task.mjs",
209
209
  "w2m-self-update": "bin/w2m-self-update.mjs",
210
- "w2m-joint": "bin/w2m-joint.mjs"
210
+ "w2m-joint": "bin/w2m-joint.mjs",
211
+ "w2m-operator": "bin/w2m-operator.mjs"
211
212
  },
212
213
  "disclosure": {
213
214
  "cloud": true,
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.14",
4
+ "version": "1.9.16",
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": [
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Secure storage for the relay operator token (v2 G0-1).
3
+ *
4
+ * The token must not travel through chat, argv, logs or environment dumps. This module stores it in
5
+ * a single 0600 file under the per-user W2M directory (or W2M_OPERATOR_TOKEN_FILE), and every read
6
+ * returns metadata plus at most a short fingerprint — never the value in logs.
7
+ *
8
+ * Resolution order used by the plugin: profile config (legacy) → this file → W2M_OPERATOR_TOKEN
9
+ * (legacy compatibility; discouraged). The file is the recommended source: it is not visible in the
10
+ * process environment and not echoed by any status output.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const MIN_LENGTH = 16;
18
+ const MAX_LENGTH = 4096;
19
+
20
+ /** Where the token file lives. */
21
+ export function operatorTokenPath(env = process.env) {
22
+ const explicit = typeof env.W2M_OPERATOR_TOKEN_FILE === 'string' ? env.W2M_OPERATOR_TOKEN_FILE.trim() : '';
23
+ if (explicit !== '') return explicit;
24
+ return join(homedir(), '.w2m', 'operator-token');
25
+ }
26
+
27
+ /** A stable, non-reversible fingerprint for display ("configured, id 3f2a1c..."). */
28
+ export function fingerprintOf(token) {
29
+ if (typeof token !== 'string' || token === '') return null;
30
+ return createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 12);
31
+ }
32
+
33
+ export function validateOperatorToken(token) {
34
+ if (typeof token !== 'string') return { ok: false, code: 'OPERATOR_TOKEN_INVALID' };
35
+ const value = token.trim();
36
+ if (value.length < MIN_LENGTH) return { ok: false, code: 'OPERATOR_TOKEN_TOO_SHORT', detail: { min: MIN_LENGTH } };
37
+ if (value.length > MAX_LENGTH) return { ok: false, code: 'OPERATOR_TOKEN_TOO_LONG', detail: { max: MAX_LENGTH } };
38
+ if (/[\r\n]/.test(value)) return { ok: false, code: 'OPERATOR_TOKEN_INVALID', detail: 'token must be a single line' };
39
+ return { ok: true, token: value };
40
+ }
41
+
42
+ /**
43
+ * Read the token file.
44
+ *
45
+ * @returns {{configured: false, path: string}
46
+ * | {configured: true, token: string, path: string, fingerprint: string, mode: number|null}}
47
+ */
48
+ export function readOperatorToken(options = {}) {
49
+ const env = options.env ?? process.env;
50
+ const path = options.path ?? operatorTokenPath(env);
51
+ if (!existsSync(path)) return { configured: false, path };
52
+ let raw;
53
+ try { raw = readFileSync(path, 'utf8'); } catch (error) {
54
+ return { configured: false, path, error: error instanceof Error ? error.message : String(error) };
55
+ }
56
+ const checked = validateOperatorToken(raw);
57
+ if (checked.ok !== true) return { configured: false, path, error: checked.code };
58
+ return { configured: true, token: checked.token, path, fingerprint: fingerprintOf(checked.token) };
59
+ }
60
+
61
+ export function writeOperatorToken(token, options = {}) {
62
+ const checked = validateOperatorToken(token);
63
+ if (checked.ok !== true) return checked;
64
+ const env = options.env ?? process.env;
65
+ const path = options.path ?? operatorTokenPath(env);
66
+ try {
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ writeFileSync(path, checked.token + '\n', { encoding: 'utf8', mode: 0o600 });
69
+ try { chmodSync(path, 0o600); } catch { /* Windows: the file ACL carries the protection */ }
70
+ } catch (error) {
71
+ return { ok: false, code: 'OPERATOR_TOKEN_WRITE_FAILED', detail: error instanceof Error ? error.message : String(error) };
72
+ }
73
+ return { ok: true, path, fingerprint: fingerprintOf(checked.token) };
74
+ }
75
+
76
+ export function clearOperatorToken(options = {}) {
77
+ const env = options.env ?? process.env;
78
+ const path = options.path ?? operatorTokenPath(env);
79
+ try { rmSync(path, { force: true }); } catch (error) {
80
+ return { ok: false, code: 'OPERATOR_TOKEN_CLEAR_FAILED', detail: error instanceof Error ? error.message : String(error) };
81
+ }
82
+ return { ok: true, path };
83
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Relay capability negotiation (v2 G0-2).
3
+ *
4
+ * A joint-dev dispatch names machines on the wire and expects the relay to honour
5
+ * `target_machines`/`executor_machine_id`. An older relay silently ignores those fields, which
6
+ * widens an intended single-machine dispatch to the whole online fleet. The dispatcher therefore
7
+ * reads the relay's advertised features first and refuses when a required feature is missing.
8
+ *
9
+ * A relay with no `features` field at all is older than this protocol: callers decide policy
10
+ * (joint-dev refuses; ordinary dispatch keeps its post-dispatch lease check).
11
+ */
12
+
13
+ /** Features a joint-dev dispatch requires from the relay. */
14
+ export const JOINT_REQUIRED_FEATURES = Object.freeze(['target-machines', 'result-projection']);
15
+
16
+ /**
17
+ * Read `/healthz` and normalise the capability fields.
18
+ *
19
+ * @param {{rabbitUrl: string, fetchImpl?: Function, timeoutMs?: number}} input
20
+ * @returns {Promise<{ok: boolean, relay_version?: string|null, features?: string[]|null, protocol_version?: number|null, error?: string}>}
21
+ */
22
+ export async function readRelayCapabilities(input) {
23
+ const base = typeof input?.rabbitUrl === 'string' ? input.rabbitUrl.replace(/\/$/, '') : '';
24
+ if (base === '') return { ok: false, error: 'rabbitUrl is required' };
25
+ const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
26
+ const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 15_000;
27
+ try {
28
+ const response = await fetchImpl(`${base}/healthz`, {
29
+ headers: { accept: 'application/json' },
30
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
31
+ });
32
+ if (response?.ok !== true) return { ok: false, error: `healthz answered HTTP ${response?.status ?? '?'}` };
33
+ const doc = await response.json();
34
+ const features = Array.isArray(doc?.features) ? doc.features.filter((entry) => typeof entry === 'string') : null;
35
+ return {
36
+ ok: true,
37
+ relay_version: typeof doc?.relay_version === 'string' ? doc.relay_version : (typeof doc?.version === 'string' ? doc.version : null),
38
+ features,
39
+ protocol_version: Number.isInteger(doc?.protocol_version) ? doc.protocol_version : null,
40
+ operator_token_required: doc?.operator_token_required === true,
41
+ };
42
+ } catch (error) {
43
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Decide whether the advertised capabilities satisfy a required set.
49
+ *
50
+ * @param {{features?: string[]|null}|null} capabilities
51
+ * @param {string[]} [required]
52
+ * @returns {{ok: boolean, code?: string, missing?: string[], unknown?: boolean}}
53
+ */
54
+ export function negotiateFeatures(capabilities, required = JOINT_REQUIRED_FEATURES) {
55
+ if (capabilities === null || capabilities?.ok !== true) {
56
+ return { ok: false, code: 'RELAY_CAPABILITIES_UNAVAILABLE', unknown: true };
57
+ }
58
+ const features = capabilities.features;
59
+ if (!Array.isArray(features)) {
60
+ // No advertisement at all: an older relay. Callers that need the fields must not guess.
61
+ return { ok: false, code: 'RELAY_CAPABILITIES_MISSING', missing: [...required], unknown: true };
62
+ }
63
+ const missing = required.filter((feature) => !features.includes(feature));
64
+ if (missing.length > 0) return { ok: false, code: 'RELAY_CAPABILITIES_MISSING', missing, unknown: false };
65
+ return { ok: true };
66
+ }
@@ -191,6 +191,7 @@ import {
191
191
  import { registerChatRoutes } from './fleet-chat-route.mjs';
192
192
  import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
193
193
  import { registerPluginUpdateRoutes } from './update-route.mjs';
194
+ import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
194
195
  import { registerCaptureRoutes } from './capture-route.mjs';
195
196
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
196
197
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
@@ -283,7 +284,7 @@ const MAX_STAGES = 16;
283
284
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
285
  * overrides it at runtime for tests and packagers.
285
286
  */
286
- const PLUGIN_VERSION = '1.9.14';
287
+ const PLUGIN_VERSION = '1.9.16';
287
288
 
288
289
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
290
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -687,6 +688,7 @@ async function loadDefineTool() {
687
688
  * p2pMode: string, stunServers: string[]}}
688
689
  */
689
690
  function readConfig(config = {}) {
691
+ let operatorTokenSource = null;
690
692
  const cwd = process.cwd();
691
693
 
692
694
  /**
@@ -872,11 +874,23 @@ function readConfig(config = {}) {
872
874
  ? process.env.W2M_PAIRING_CODE.trim()
873
875
  : null),
874
876
  // §5: the operator credential. Only `w2m_run` uses it.
875
- operatorToken: typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
876
- ? config.operatorToken.trim()
877
- : (typeof process.env.W2M_OPERATOR_TOKEN === 'string' && process.env.W2M_OPERATOR_TOKEN.trim() !== ''
877
+ // v2 G0-1: the token file (0600) is the recommended source; profile config stays for legacy,
878
+ // env last. The value never reaches a status output - only the source and a fingerprint do.
879
+ operatorToken: (() => {
880
+ const fromConfig = typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
881
+ ? config.operatorToken.trim()
882
+ : null;
883
+ if (fromConfig !== null) { operatorTokenSource = 'config'; return fromConfig; }
884
+ const secure = readOperatorToken();
885
+ if (secure.configured === true) { operatorTokenSource = 'file'; return secure.token; }
886
+ const fromEnv = typeof process.env.W2M_OPERATOR_TOKEN === 'string' && process.env.W2M_OPERATOR_TOKEN.trim() !== ''
878
887
  ? process.env.W2M_OPERATOR_TOKEN.trim()
879
- : null),
888
+ : null;
889
+ if (fromEnv !== null) { operatorTokenSource = 'env'; return fromEnv; }
890
+ operatorTokenSource = null;
891
+ return null;
892
+ })(),
893
+ operatorTokenSource,
880
894
  // ---------------------------------------------------------------------------------------------
881
895
  // v0.2.3: daily self-update
882
896
  // ---------------------------------------------------------------------------------------------
@@ -4881,7 +4895,9 @@ const result = await request({
4881
4895
  http_status: probe.status,
4882
4896
  protocol_version: health.protocol_version ?? null,
4883
4897
  error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
4884
- relay_id: health.relay_id ?? null,
4898
+ relay_id: health.relay_id ?? null,
4899
+ relay_version: health.relay_version ?? health.version ?? null,
4900
+ features: Array.isArray(health.features) ? health.features : null,
4885
4901
  uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
4886
4902
  started_at: health.started_at ?? null,
4887
4903
  base_path: health.base_path ?? null,
@@ -4892,7 +4908,7 @@ const result = await request({
4892
4908
  persistence: health.persistence ?? null,
4893
4909
  // Which of the §4 fields this relay actually sent. A v1 relay sends none of them, and a
4894
4910
  // reader should be able to tell "field absent" from "field false".
4895
- diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required']
4911
+ diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required', 'relay_version', 'features']
4896
4912
  .filter((key) => health[key] !== undefined && health[key] !== null),
4897
4913
  };
4898
4914
  } catch (error) {
@@ -4996,6 +5012,7 @@ const result = await request({
4996
5012
  // wrong one without spending a dispatch, and `required_by_relay` is what makes the two
4997
5013
  // halves of the mismatch (client has none / relay demands one) visible side by side.
4998
5014
  operatorToken_configured: cfg.operatorToken !== null,
5015
+ operatorToken_fingerprint: cfg.operatorToken === null ? null : fingerprintOf(cfg.operatorToken),
4999
5016
  operatorToken_source: cfg.operatorToken !== null
5000
5017
  ? (typeof config?.operatorToken === 'string' && config.operatorToken.trim() !== ''
5001
5018
  ? 'config'
@@ -22,6 +22,17 @@ function readRelayVersion() {
22
22
  return process.env.W2M_RELAY_VERSION ?? 'unknown';
23
23
  }
24
24
  export const RELAY_VERSION = readRelayVersion();
25
+
26
+ /**
27
+ * Capabilities a dispatcher may rely on. A relay that does not advertise a feature is an older
28
+ * relay: joint-dev must refuse rather than assume the field will be honoured.
29
+ */
30
+ export const RELAY_FEATURES = Object.freeze([
31
+ 'target-machines',
32
+ 'result-projection',
33
+ 'guard-v1',
34
+ 'relay-version-v1',
35
+ ]);
25
36
  import fs from 'node:fs';
26
37
  import os from 'node:os';
27
38
  import path from 'node:path';
@@ -1591,7 +1602,9 @@ export class RelayServer {
1591
1602
  rabbit_time: this.state.nowIso(),
1592
1603
  uptime_ms: this.state.nowMs() - this._startedAtMs,
1593
1604
  relay_id: this.relayId,
1594
- version: RELAY_VERSION,
1605
+ version: RELAY_VERSION,
1606
+ relay_version: RELAY_VERSION,
1607
+ features: RELAY_FEATURES,
1595
1608
  started_at: rfc3339(this._startedAtMs),
1596
1609
  effective_scheme: this.effectiveScheme(req),
1597
1610
  base_path: this.basePath,