@twinsearth/w2m-dsh-plugin 1.9.15 → 1.9.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.15",
3
+ "version": "1.9.17",
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 test/operator-token.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 test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.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",
@@ -208,7 +208,8 @@
208
208
  "w2m-hidden-task": "bin/w2m-hidden-task.mjs",
209
209
  "w2m-self-update": "bin/w2m-self-update.mjs",
210
210
  "w2m-joint": "bin/w2m-joint.mjs",
211
- "w2m-operator": "bin/w2m-operator.mjs"
211
+ "w2m-operator": "bin/w2m-operator.mjs",
212
+ "w2m-scrub": "bin/w2m-scrub.mjs"
212
213
  },
213
214
  "disclosure": {
214
215
  "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.15",
4
+ "version": "1.9.17",
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": [
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * joint-dev 计划持久化与人工审批门(本地文件,0600)。
3
3
  *
4
- * 安全不变量(v2 预置):
4
+ * 安全不变量(v2):
5
5
  * · plan_sha256 必须由本模块重算,与文件内容不符即 PLAN_TAMPERED;
6
6
  * · planSha 必须是 64 位小写十六进制,禁止路径穿越;
7
- * · 审批绑定 plan_sha256 + approver + authority + 过期时间,approval_sha256 读回时重算;
8
- * · 未审批的计划禁止 dispatch(assertDispatchAllowed 是唯一入口)。
9
- *
10
- * 审批只由操作员 CLI/UI 写入,不暴露为 agent 工具。
7
+ * · 审批绑定 plan_sha256 + approver + authority(70..100) + 有效期(60s..10min);
8
+ * · 审批由 operator Ed25519 私钥签名;签名覆盖除 sig/approval_sha256/operator_key 外的记录体,
9
+ * key_id 必须等于公钥指纹(防换公钥),可选 trustedOperatorKeyIds 做信任锚;
10
+ * · 审批写入原子(tmp→rename),dispatch 前置门可强制要求签名;
11
+ * · consumeApproval 用 O_EXCL 保证"一次审批只能派发一次"。
11
12
  */
12
13
  import { createHash } from 'node:crypto';
13
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeSync } from 'node:fs';
14
15
  import { join } from 'node:path';
15
16
 
17
+ import { keyIdOf, verifyApprovalPayload } from '../operator-key.mjs';
18
+
16
19
  const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
17
20
  const PLAN_SHA = /^[0-9a-f]{64}$/;
18
- const DEFAULT_APPROVAL_TTL_MS = 24 * 60 * 60 * 1000;
21
+ const APPROVAL_TTL_MIN_MS = 60_000;
22
+ const APPROVAL_TTL_MAX_MS = 10 * 60 * 1000;
19
23
 
20
24
  const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
21
25
  const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
26
+ const consumedDir = (stateDir) => join(stateDir, 'joint-dev', 'consumed');
22
27
 
23
28
  /** Hash of the plan body WITHOUT plan_sha256 — the same rule buildPlan uses. */
24
29
  export function planHashOf(plan) {
@@ -42,6 +47,18 @@ function readPlanFile(stateDir, planSha) {
42
47
  return { ok: true, plan, path, saved_at: parsed.saved_at ?? null };
43
48
  }
44
49
 
50
+ function writeFileAtomic(path, text) {
51
+ const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
52
+ const fd = openSync(tmp, 'w', 0o600);
53
+ try {
54
+ writeSync(fd, text);
55
+ fsyncSync(fd);
56
+ } finally {
57
+ closeSync(fd);
58
+ }
59
+ renameSync(tmp, path);
60
+ }
61
+
45
62
  export function savePlan({ stateDir, plan, now = Date.now() }) {
46
63
  if (plan === null || typeof plan !== 'object' || !PLAN_SHA.test(String(plan.plan_sha256 ?? ''))) {
47
64
  return { ok: false, code: 'PLAN_INVALID' };
@@ -53,10 +70,7 @@ export function savePlan({ stateDir, plan, now = Date.now() }) {
53
70
  mkdirSync(planDir(stateDir), { recursive: true });
54
71
  const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
55
72
  if (existsSync(path)) return { ok: true, path, reused: true };
56
- const tmp = path + '.tmp-' + process.pid;
57
- writeFileSync(tmp, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
58
- // rename is atomic on the same volume; a crash leaves only the tmp file behind.
59
- renameSync(tmp, path);
73
+ writeFileAtomic(path, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2));
60
74
  return { ok: true, path, reused: false };
61
75
  }
62
76
 
@@ -72,30 +86,38 @@ export function listPlans({ stateDir, limit = 50 }) {
72
86
  const entries = [];
73
87
  for (const name of readdirSync(dir)) {
74
88
  if (!name.endsWith('.json')) continue;
75
- const path = join(dir, name);
89
+ const sha = name.slice(0, -5);
90
+ if (!PLAN_SHA.test(sha)) continue;
76
91
  try {
77
- const stat = statSync(path);
78
- entries.push({ plan_sha256: name.slice(0, -5), saved_at_ms: stat.mtimeMs });
79
- } catch { /* a vanished plan is simply absent */ }
92
+ entries.push({ plan_sha256: sha, saved_at_ms: statSync(join(dir, name)).mtimeMs });
93
+ } catch { /* absent */ }
80
94
  }
81
95
  entries.sort((a, b) => b.saved_at_ms - a.saved_at_ms);
82
96
  return { ok: true, plans: entries.slice(0, Math.max(1, Math.min(200, limit))), total: entries.length };
83
97
  }
84
98
 
85
99
  /**
86
- * 人工审批。authority 最低 70;TTL 默认 24h;过期/哈希不符/计划被替换时 dispatch 拒绝。
100
+ * 人工审批。authority 70..100;TTL 60s..10min(决策 2);传入 signer 时审批带 Ed25519 签名。
101
+ * signer 只被调用一次:签名覆盖最终要落盘的 body(不含 operator_key / sig / approval_sha256)。
102
+ *
103
+ * @param {{stateDir: string, planSha: string, approver: string, authority: number, note?: string, now?: number, ttlMs?: number,
104
+ * signer?: (body: object) => {sig: string, key_id: string, public_key_pem: string}}} input
87
105
  */
88
- export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now(), ttlMs = DEFAULT_APPROVAL_TTL_MS }) {
106
+ export function approvePlan(input) {
107
+ const { stateDir, planSha, approver, authority, note = '', now = Date.now(), ttlMs = APPROVAL_TTL_MAX_MS, signer } = input;
89
108
  if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
90
109
  if (typeof approver !== 'string' || approver.trim() === '') return { ok: false, code: 'APPROVER_REQUIRED' };
91
- if (!Number.isInteger(authority) || authority < 70) return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority } };
92
- if (!Number.isInteger(ttlMs) || ttlMs < 60_000 || ttlMs > 7 * 24 * 60 * 60 * 1000) {
110
+ if (!Number.isInteger(authority) || authority < 70 || authority > 100) {
111
+ return { ok: false, code: 'APPROVAL_AUTHORITY_INVALID', detail: { authority } };
112
+ }
113
+ if (!Number.isInteger(ttlMs) || ttlMs < APPROVAL_TTL_MIN_MS || ttlMs > APPROVAL_TTL_MAX_MS) {
93
114
  return { ok: false, code: 'APPROVAL_TTL_INVALID', detail: { ttlMs } };
94
115
  }
95
116
  const loaded = readPlanFile(stateDir, planSha);
96
117
  if (loaded.ok !== true) return loaded;
97
118
  mkdirSync(approvalDir(stateDir), { recursive: true });
98
- const body = {
119
+
120
+ const base = {
99
121
  v: 2,
100
122
  plan_sha256: planSha,
101
123
  approver,
@@ -104,13 +126,29 @@ export function approvePlan({ stateDir, planSha, approver, authority, note = '',
104
126
  approved_at: new Date(now).toISOString(),
105
127
  expires_at: new Date(now + ttlMs).toISOString(),
106
128
  };
107
- const record = { ...body, approval_sha256: sha256(JSON.stringify(body)) };
129
+ let record = base;
130
+ if (typeof signer === 'function') {
131
+ const material = signer({ ...base });
132
+ if (typeof material?.sig !== 'string' || material.sig === '' || typeof material?.public_key_pem !== 'string') {
133
+ return { ok: false, code: 'APPROVAL_SIGN_FAILED' };
134
+ }
135
+ const keyId = String(material.key_id ?? '');
136
+ if (keyId === '' || keyId !== keyIdOf(material.public_key_pem)) {
137
+ return { ok: false, code: 'APPROVAL_KEY_MISMATCH' };
138
+ }
139
+ record = { ...base, operator_key: { key_id: keyId, public_key_pem: material.public_key_pem }, sig: material.sig };
140
+ }
141
+ const stored = { ...record, approval_sha256: sha256(JSON.stringify(record)) };
108
142
  const path = join(approvalDir(stateDir), planSha + '.json');
109
- writeFileSync(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
110
- return { ok: true, approval: record, path };
143
+ writeFileAtomic(path, JSON.stringify(stored, null, 2));
144
+ return { ok: true, approval: stored, path, signed: typeof record.sig === 'string' };
111
145
  }
112
146
 
113
- export function loadApproval({ stateDir, planSha, now = Date.now() }) {
147
+ /**
148
+ * @param {{stateDir: string, planSha: string, now?: number, trustedOperatorKeyIds?: string[]}} input
149
+ */
150
+ export function loadApproval(input) {
151
+ const { stateDir, planSha, now = Date.now(), trustedOperatorKeyIds } = input;
114
152
  if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
115
153
  const path = join(approvalDir(stateDir), planSha + '.json');
116
154
  if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
@@ -119,19 +157,66 @@ export function loadApproval({ stateDir, planSha, now = Date.now() }) {
119
157
  if (record?.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
120
158
  const { approval_sha256: declared, ...body } = record;
121
159
  if (sha256(JSON.stringify(body)) !== declared) return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
122
- if (!Number.isInteger(record.authority) || record.authority < 70) {
123
- return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority: record.authority } };
160
+ if (!Number.isInteger(record.authority) || record.authority < 70 || record.authority > 100) {
161
+ return { ok: false, code: 'APPROVAL_AUTHORITY_INVALID', detail: { authority: record.authority } };
124
162
  }
163
+ const approved = Date.parse(record.approved_at ?? '');
125
164
  const expires = Date.parse(record.expires_at ?? '');
126
- if (Number.isFinite(expires) && now > expires) return { ok: false, code: 'APPROVAL_EXPIRED', detail: { expires_at: record.expires_at } };
127
- return { ok: true, approval: record };
165
+ if (!Number.isFinite(approved) || !Number.isFinite(expires)) return { ok: false, code: 'APPROVAL_EXPIRY_INVALID' };
166
+ if (expires - approved < APPROVAL_TTL_MIN_MS || expires - approved > APPROVAL_TTL_MAX_MS) {
167
+ return { ok: false, code: 'APPROVAL_TTL_INVALID' };
168
+ }
169
+ if (now > expires) return { ok: false, code: 'APPROVAL_EXPIRED', detail: { expires_at: record.expires_at } };
170
+ let strength = 'digest';
171
+ if (typeof record.sig === 'string') {
172
+ const pem = record?.operator_key?.public_key_pem;
173
+ const keyId = record?.operator_key?.key_id;
174
+ if (typeof pem !== 'string' || pem === '' || typeof keyId !== 'string' || keyId === '') {
175
+ return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
176
+ }
177
+ if (keyId !== keyIdOf(pem)) return { ok: false, code: 'APPROVAL_KEY_MISMATCH' };
178
+ if (Array.isArray(trustedOperatorKeyIds) && !trustedOperatorKeyIds.includes(keyId)) {
179
+ return { ok: false, code: 'APPROVAL_KEY_UNTRUSTED', detail: { key_id: keyId } };
180
+ }
181
+ if (verifyApprovalPayload(record, record.sig, pem) !== true) {
182
+ return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
183
+ }
184
+ strength = 'signature';
185
+ }
186
+ return { ok: true, approval: record, strength };
128
187
  }
129
188
 
130
- /** dispatch 前置门:计划必须存在、未被篡改,且已审批、未过期。 */
131
- export function assertDispatchAllowed({ stateDir, planSha, now = Date.now() }) {
189
+ /**
190
+ * 一次性消费:O_EXCL 写 consumed/<approval_sha>.json;第二次调用返回 APPROVAL_ALREADY_USED。
191
+ */
192
+ export function consumeApproval({ stateDir, planSha, approvalSha, now = Date.now() }) {
193
+ if (!PLAN_SHA.test(String(planSha ?? '')) || !PLAN_SHA.test(String(approvalSha ?? ''))) {
194
+ return { ok: false, code: 'APPROVAL_SHA_INVALID' };
195
+ }
196
+ mkdirSync(consumedDir(stateDir), { recursive: true });
197
+ const path = join(consumedDir(stateDir), approvalSha + '.json');
198
+ try {
199
+ const fd = openSync(path, 'wx', 0o600);
200
+ try {
201
+ writeSync(fd, JSON.stringify({ plan_sha256: planSha, approval_sha256: approvalSha, consumed_at: new Date(now).toISOString() }));
202
+ fsyncSync(fd);
203
+ } finally { closeSync(fd); }
204
+ return { ok: true, path };
205
+ } catch (error) {
206
+ if (error?.code === 'EEXIST') return { ok: false, code: 'APPROVAL_ALREADY_USED', detail: { path } };
207
+ return { ok: false, code: 'APPROVAL_CONSUME_FAILED', detail: error instanceof Error ? error.message : String(error) };
208
+ }
209
+ }
210
+
211
+ /** dispatch 前置门:计划存在未篡改、审批有效且未过期;requireSignature 时必须是签名审批。 */
212
+ export function assertDispatchAllowed(input) {
213
+ const { stateDir, planSha, now = Date.now(), requireSignature = false, trustedOperatorKeyIds } = input;
132
214
  const plan = loadPlan({ stateDir, planSha });
133
215
  if (plan.ok !== true) return { ok: false, code: plan.code ?? 'PLAN_NOT_FOUND' };
134
- const approval = loadApproval({ stateDir, planSha, now });
216
+ const approval = loadApproval({ stateDir, planSha, now, trustedOperatorKeyIds });
135
217
  if (approval.ok !== true) return { ok: false, code: approval.code ?? 'APPROVAL_REQUIRED' };
136
- return { ok: true, plan: plan.plan, approval: approval.approval };
218
+ if (requireSignature === true && approval.strength !== 'signature') {
219
+ return { ok: false, code: 'APPROVAL_SIGNATURE_REQUIRED' };
220
+ }
221
+ return { ok: true, plan: plan.plan, approval: approval.approval, strength: approval.strength };
137
222
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Joint-dev relay dispatch (v2 S2).
3
+ *
4
+ * Turns an approved plan into relay tasks: one step = one broadcast task pinned to the step's
5
+ * machine via `executor_machine_id`. Capabilities are negotiated first (an older relay that cannot
6
+ * honour the pin is refused, never assumed), and the leases the relay returns are checked: a lease
7
+ * on any machine other than the pinned one cancels the task and fails the dispatch.
8
+ *
9
+ * Only the control plane is written here: plan digest, step id, command hash and the argv the
10
+ * executor needs. Nothing is uploaded; artifacts are the separate sink path (S4).
11
+ */
12
+ import { readRelayCapabilities, negotiateFeatures } from '../relay-capabilities.mjs';
13
+
14
+ const trimBase = (value) => (typeof value === 'string' ? value.replace(/\/+$/, '') : '');
15
+
16
+ /**
17
+ * @param {{rabbitUrl: string, token: string, plan: object, approval: object, fetchImpl?: Function, timeoutMs?: number, includeOffline?: boolean}} input
18
+ */
19
+ export async function dispatchPlanToRelay(input) {
20
+ const rabbitUrl = trimBase(input?.rabbitUrl);
21
+ const token = typeof input?.token === 'string' ? input.token : '';
22
+ const plan = input?.plan ?? null;
23
+ const approval = input?.approval ?? null;
24
+ if (rabbitUrl === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
25
+ if (token === '') return { ok: false, code: 'OPERATOR_TOKEN_MISSING' };
26
+ if (plan === null || typeof plan.plan_sha256 !== 'string') return { ok: false, code: 'PLAN_INVALID' };
27
+ if (approval === null || typeof approval.approval_sha256 !== 'string') return { ok: false, code: 'APPROVAL_REQUIRED' };
28
+
29
+ const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
30
+ const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 60_000;
31
+ const signalOf = () => (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
32
+ const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
33
+
34
+ const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
35
+ const negotiation = negotiateFeatures(capabilities);
36
+ if (negotiation.ok !== true) {
37
+ return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
38
+ }
39
+
40
+ const tasks = [];
41
+ for (const step of plan.steps) {
42
+ const body = {
43
+ mode: 'broadcast',
44
+ executor_machine_id: step.machine_id,
45
+ command_argv: step.command_argv,
46
+ command_hash: step.command_hash,
47
+ base_commit: plan.base_commit,
48
+ plan_sha256: plan.plan_sha256,
49
+ approval_sha256: approval.approval_sha256,
50
+ step_id: step.step_id,
51
+ ...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
52
+ ...(input?.includeOffline === false ? {} : { include_offline: true }),
53
+ };
54
+ let response;
55
+ try {
56
+ response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: JSON.stringify(body), signal: signalOf() });
57
+ } catch (error) {
58
+ return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error) };
59
+ }
60
+ const doc = await response.json().catch(() => null);
61
+ if (response.ok !== true) {
62
+ return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc };
63
+ }
64
+ const taskId = doc?.task_id ?? doc?.task?.task_id ?? null;
65
+ const leases = Array.isArray(doc?.leases) ? doc.leases : [];
66
+ const foreign = leases.filter((lease) => lease?.state !== 'observing' && lease?.machine_id !== step.machine_id);
67
+ if (foreign.length > 0) {
68
+ if (taskId !== null) {
69
+ try {
70
+ await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}/cancel`, {
71
+ method: 'POST', headers, body: JSON.stringify({ reason: 'SELECTION_NOT_HONOURED' }), signal: signalOf(),
72
+ });
73
+ } catch { /* the refusal below is still the answer */ }
74
+ }
75
+ return {
76
+ ok: false,
77
+ code: 'SELECTION_NOT_HONOURED',
78
+ detail: { step_id: step.step_id, named: step.machine_id, leased: foreign.map((lease) => lease.machine_id), task_id: taskId },
79
+ tasks,
80
+ };
81
+ }
82
+ tasks.push({
83
+ step_id: step.step_id,
84
+ task_id: taskId,
85
+ leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
86
+ });
87
+ }
88
+ return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, tasks };
89
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Operator Ed25519 key (v2 decisions 1 & 2).
3
+ *
4
+ * The key is generated once, stored as a 0600 PKCS#8 PEM under `~/.w2m/operator-key.pem`
5
+ * (`W2M_OPERATOR_KEY_FILE` overrides), and used to sign plan approvals. The public key travels
6
+ * with the approval; the private key never leaves this machine and is never logged.
7
+ */
8
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+
13
+ export function operatorKeyPath(env = process.env) {
14
+ const explicit = typeof env.W2M_OPERATOR_KEY_FILE === 'string' ? env.W2M_OPERATOR_KEY_FILE.trim() : '';
15
+ if (explicit !== '') return explicit;
16
+ return join(homedir(), '.w2m', 'operator-key.pem');
17
+ }
18
+
19
+ export function keyIdOf(publicKeyPem) {
20
+ return createHash('sha256').update(publicKeyPem, 'utf8').digest('hex').slice(0, 16);
21
+ }
22
+
23
+ /** The exact bytes that are signed: the approval body without its signature fields. */
24
+ export function approvalPayloadOf(body) {
25
+ const { sig, approval_sha256: _ignored, operator_key: _key, ...rest } = body ?? {};
26
+ return Buffer.from(JSON.stringify(rest), 'utf8');
27
+ }
28
+
29
+ export function signApprovalPayload(body, privateKey) {
30
+ return sign(null, approvalPayloadOf(body), privateKey).toString('base64');
31
+ }
32
+
33
+ export function verifyApprovalPayload(body, signatureBase64, publicKeyPem) {
34
+ try {
35
+ const key = createPublicKey(publicKeyPem);
36
+ return verify(null, approvalPayloadOf(body), key, Buffer.from(String(signatureBase64), 'base64'));
37
+ } catch { return false; }
38
+ }
39
+
40
+ /** Load the existing key, or create one and write it 0600. */
41
+ export function ensureOperatorKey(options = {}) {
42
+ const env = options.env ?? process.env;
43
+ const path = options.path ?? operatorKeyPath(env);
44
+ if (existsSync(path)) {
45
+ const loaded = loadOperatorKey({ path });
46
+ return loaded.ok === true ? { ...loaded, created: false } : loaded;
47
+ }
48
+ try {
49
+ const { privateKey } = generateKeyPairSync('ed25519');
50
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ writeFileSync(path, pem, { encoding: 'utf8', mode: 0o600 });
53
+ try { chmodSync(path, 0o600); } catch { /* Windows ACL */ }
54
+ const loaded = loadOperatorKey({ path });
55
+ return loaded.ok === true ? { ...loaded, created: true } : loaded;
56
+ } catch (error) {
57
+ return { ok: false, code: 'OPERATOR_KEY_CREATE_FAILED', detail: error instanceof Error ? error.message : String(error) };
58
+ }
59
+ }
60
+
61
+ export function loadOperatorKey(options = {}) {
62
+ const env = options.env ?? process.env;
63
+ const path = options.path ?? operatorKeyPath(env);
64
+ if (!existsSync(path)) return { ok: false, code: 'OPERATOR_KEY_MISSING', path };
65
+ try {
66
+ const pem = readFileSync(path, 'utf8');
67
+ const privateKey = createPrivateKey(pem);
68
+ const publicKeyPem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
69
+ return { ok: true, path, privateKey, publicKeyPem, key_id: keyIdOf(publicKeyPem) };
70
+ } catch (error) {
71
+ return { ok: false, code: 'OPERATOR_KEY_UNREADABLE', path, detail: error instanceof Error ? error.message : String(error) };
72
+ }
73
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `/api/w2m/operator-token` (v2 S0-1): the settings-page half of the secure token setup.
3
+ *
4
+ * Wire:
5
+ * GET /api/w2m/operator-token -> { ok, configured, source, fingerprint, path }
6
+ * POST /api/w2m/operator-token { token } -> { ok, configured: true, source: 'file', fingerprint, path }
7
+ *
8
+ * The token value is written straight into the 0600 file and never echoed back: the response carries
9
+ * only a fingerprint, so the page can confirm "configured" without the secret ever entering a log,
10
+ * a transcript or the DOM's value after the save.
11
+ */
12
+ import { fingerprintOf, readOperatorToken, writeOperatorToken } from './operator-token.mjs';
13
+
14
+ export const OPERATOR_TOKEN_ROUTE_PATH = '/api/w2m/operator-token';
15
+ const NO_STORE = { 'cache-control': 'no-store' };
16
+
17
+ /**
18
+ * @param {object} ctx Cordis context
19
+ * @param {{path?: () => string|null, configuredToken?: () => string|null}} [deps]
20
+ */
21
+ export function registerOperatorTokenRoute(ctx, deps = {}) {
22
+ const status = { mounted: false, reason: 'the host has no Connection service' };
23
+ if (typeof ctx?.inject !== 'function') return status;
24
+ ctx.inject(['connection'], (webCtx) => {
25
+ const registry = webCtx?.connection?.fetch;
26
+ if (registry === undefined || typeof registry.register !== 'function') {
27
+ status.reason = 'the connection service carries no fetch registry';
28
+ return;
29
+ }
30
+ const pathOf = () => {
31
+ const configured = typeof deps?.path === 'function' ? deps.path() : null;
32
+ return typeof configured === 'string' && configured !== '' ? configured : undefined;
33
+ };
34
+ registry.register({
35
+ path: OPERATOR_TOKEN_ROUTE_PATH,
36
+ methods: ['GET', 'POST'],
37
+ requestBody: 'buffered',
38
+ fetch: async (request) => {
39
+ if (request.method === 'GET') {
40
+ const path = pathOf();
41
+ const read = path === undefined ? readOperatorToken() : readOperatorToken({ path });
42
+ const configuredToken = typeof deps?.configuredToken === 'function' ? deps.configuredToken() : null;
43
+ const fromFile = read.configured === true;
44
+ const source = fromFile ? 'file' : (typeof configuredToken === 'string' && configuredToken !== '' ? 'config-or-env' : null);
45
+ return Response.json({
46
+ ok: true,
47
+ configured: fromFile || source !== null,
48
+ source,
49
+ fingerprint: fromFile ? read.fingerprint : (source === null ? null : fingerprintOf(configuredToken)),
50
+ path: read.path ?? path ?? null,
51
+ }, { headers: NO_STORE });
52
+ }
53
+ let body = null;
54
+ try { body = await request.json(); } catch { /* refused below */ }
55
+ const token = typeof body?.token === 'string' ? body.token : '';
56
+ if (token.trim() === '') {
57
+ return Response.json({ ok: false, code: 'OPERATOR_TOKEN_REQUIRED' }, { status: 400, headers: NO_STORE });
58
+ }
59
+ const path = pathOf();
60
+ const written = path === undefined ? writeOperatorToken(token) : writeOperatorToken(token, { path });
61
+ if (written.ok !== true) {
62
+ return Response.json({ ok: false, code: written.code, detail: written.detail ?? null }, { status: 400, headers: NO_STORE });
63
+ }
64
+ return Response.json({ ok: true, configured: true, source: 'file', fingerprint: written.fingerprint, path: written.path }, { headers: NO_STORE });
65
+ },
66
+ });
67
+ status.mounted = true;
68
+ status.reason = null;
69
+ });
70
+ return status;
71
+ }
@@ -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 { registerOperatorTokenRoute } from './operator-token-route.mjs';
194
195
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
195
196
  import { registerCaptureRoutes } from './capture-route.mjs';
196
197
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
@@ -284,7 +285,7 @@ const MAX_STAGES = 16;
284
285
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
285
286
  * overrides it at runtime for tests and packagers.
286
287
  */
287
- const PLUGIN_VERSION = '1.9.15';
288
+ const PLUGIN_VERSION = '1.9.17';
288
289
 
289
290
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
290
291
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3583,6 +3584,7 @@ export async function apply(ctx, config = {}) {
3583
3584
  // v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
3584
3585
  // verified updater (SHA-256 checked, old package backed up, restart required afterwards).
3585
3586
  const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
3587
+ const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
3586
3588
 
3587
3589
  // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3588
3590
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
@@ -4896,6 +4898,8 @@ const result = await request({
4896
4898
  protocol_version: health.protocol_version ?? null,
4897
4899
  error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
4898
4900
  relay_id: health.relay_id ?? null,
4901
+ relay_version: health.relay_version ?? health.version ?? null,
4902
+ features: Array.isArray(health.features) ? health.features : null,
4899
4903
  uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
4900
4904
  started_at: health.started_at ?? null,
4901
4905
  base_path: health.base_path ?? null,
@@ -4906,7 +4910,7 @@ const result = await request({
4906
4910
  persistence: health.persistence ?? null,
4907
4911
  // Which of the §4 fields this relay actually sent. A v1 relay sends none of them, and a
4908
4912
  // reader should be able to tell "field absent" from "field false".
4909
- diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required']
4913
+ diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required', 'relay_version', 'features']
4910
4914
  .filter((key) => health[key] !== undefined && health[key] !== null),
4911
4915
  };
4912
4916
  } catch (error) {