@twinsearth/w2m-dsh-plugin 1.9.16 → 1.9.19

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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/w2m-joint.mjs +36 -5
  6. package/bin/w2m-scrub.mjs +78 -0
  7. package/codex/mcp-servers.toml +1 -1
  8. package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
  9. package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
  10. package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
  11. package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
  12. package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
  13. package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
  14. package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
  15. package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
  16. package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
  17. package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
  18. package/lib/client.js +474 -368
  19. package/package.json +4 -3
  20. package/plugin.json +1 -1
  21. package/src/agent/agent.mjs +6 -1
  22. package/src/agent/sinks/dir-sink.mjs +59 -13
  23. package/src/plugin/fleet-chat-route.mjs +7 -1
  24. package/src/plugin/joint-dev/audit-file.mjs +70 -37
  25. package/src/plugin/joint-dev/merge-engine.mjs +102 -0
  26. package/src/plugin/joint-dev/plan-store.mjs +117 -32
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
  28. package/src/plugin/operator-key.mjs +73 -0
  29. package/src/plugin/operator-token-route.mjs +71 -0
  30. package/src/plugin/tools-joint.mjs +97 -0
  31. package/src/plugin/tools.mjs +8 -3
  32. package/src/relay/fleet-messages.mjs +22 -0
  33. package/src/relay/offer-journal.mjs +71 -0
  34. package/src/relay/p0-projection.mjs +10 -0
  35. package/src/relay/report.mjs +40 -2
  36. package/src/relay/scrub.mjs +94 -81
  37. package/src/relay/server.mjs +2043 -2038
  38. package/src/relay/state.mjs +3351 -3300
  39. package/src/util/artifact-ref.mjs +88 -0
  40. package/src/util/redline-core.mjs +2 -1
@@ -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,171 @@
1
+ /**
2
+ * Joint-dev relay dispatch (v2 S2).
3
+ *
4
+ * Turns an approved plan into relay tasks:
5
+ * · steps are dispatched in dependency order; a step's `depends_on` is mapped to the task ids of
6
+ * the steps it waits for, so the relay's own dependency gate keeps them from running together;
7
+ * · task ids are deterministic (`jd-<sha256(plan|step)>`), so retrying a dispatch does not create
8
+ * a second task — a relay `TASK_EXISTS` answer is reported as "already dispatched";
9
+ * · every control envelope is checked by `inspectControlMessage` (16 KiB, field whitelist, no
10
+ * data-plane bytes) and the attempt/outcome is written to the local audit file;
11
+ * · capabilities are negotiated first (an older relay is refused), and the leases the relay
12
+ * returns are checked: a lease on a machine that was not pinned cancels the task and fails.
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+
16
+ import { appendAudit } from './audit-file.mjs';
17
+ import { inspectControlMessage } from './redline.mjs';
18
+ import { readRelayCapabilities, negotiateFeatures } from '../relay-capabilities.mjs';
19
+
20
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
21
+ const trimBase = (value) => (typeof value === 'string' ? value.replace(/\/+$/, '') : '');
22
+ const taskIdOf = (planSha, stepId) => 'jd-' + sha256(planSha + '|' + stepId).slice(0, 26);
23
+
24
+ /** Kahn order over the plan's dependency graph; unknown dependencies are a refusal, not a guess. */
25
+ function orderSteps(steps) {
26
+ const byId = new Map(steps.map((step) => [step.step_id, step]));
27
+ for (const step of steps) {
28
+ for (const dep of step.depends_on ?? []) {
29
+ if (!byId.has(dep)) return { ok: false, code: 'PLAN_DEPENDENCY_UNKNOWN', detail: { step_id: step.step_id, depends_on: dep } };
30
+ }
31
+ }
32
+ const placed = new Set();
33
+ const ordered = [];
34
+ let remaining = steps.slice();
35
+ while (remaining.length > 0) {
36
+ const ready = remaining.filter((step) => (step.depends_on ?? []).every((dep) => placed.has(dep)));
37
+ if (ready.length === 0) return { ok: false, code: 'PLAN_DEPENDENCY_CYCLE' };
38
+ for (const step of ready) { ordered.push(step); placed.add(step.step_id); }
39
+ remaining = remaining.filter((step) => !placed.has(step.step_id));
40
+ }
41
+ return { ok: true, steps: ordered };
42
+ }
43
+
44
+ function writeAudit(file, event) {
45
+ if (typeof file !== 'string' || file === '') return;
46
+ try { appendAudit(file, event); } catch { /* an audit write must not change the dispatch outcome */ }
47
+ }
48
+
49
+ /**
50
+ * @param {{rabbitUrl: string, token: string, plan: object, approval: object, fetchImpl?: Function,
51
+ * timeoutMs?: number, includeOffline?: boolean, auditFile?: string}} input
52
+ */
53
+ export async function dispatchPlanToRelay(input) {
54
+ const rabbitUrl = trimBase(input?.rabbitUrl);
55
+ const token = typeof input?.token === 'string' ? input.token : '';
56
+ const plan = input?.plan ?? null;
57
+ const approval = input?.approval ?? null;
58
+ if (rabbitUrl === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
59
+ if (token === '') return { ok: false, code: 'OPERATOR_TOKEN_MISSING' };
60
+ if (plan === null || typeof plan.plan_sha256 !== 'string') return { ok: false, code: 'PLAN_INVALID' };
61
+ if (!Array.isArray(plan.steps) || plan.steps.length === 0) return { ok: false, code: 'PLAN_INVALID' };
62
+ if (approval === null || typeof approval.approval_sha256 !== 'string') return { ok: false, code: 'APPROVAL_REQUIRED' };
63
+ for (const step of plan.steps) {
64
+ if (typeof step?.step_id !== 'string' || step.step_id === '' || typeof step?.machine_id !== 'string' || step.machine_id === '' || !Array.isArray(step?.command_argv) || step.command_argv.length === 0) {
65
+ return { ok: false, code: 'PLAN_STEP_INVALID', detail: { step_id: step?.step_id ?? null } };
66
+ }
67
+ }
68
+ const ordered = orderSteps(plan.steps);
69
+ if (ordered.ok !== true) return { ok: false, code: ordered.code, detail: ordered.detail ?? null };
70
+
71
+ const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
72
+ const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 60_000;
73
+ const signalOf = () => (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
74
+ const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
75
+ const auditFile = typeof input?.auditFile === 'string' ? input.auditFile : '';
76
+
77
+ // R3: when a device token is supplied, every target machine must advertise the same plugin version.
78
+ if (typeof input?.deviceToken === 'string' && input.deviceToken !== '') {
79
+ const expected = typeof input?.expectedVersion === 'string' ? input.expectedVersion : null;
80
+ if (expected !== null) {
81
+ let fleet;
82
+ try {
83
+ const response = await fetchImpl(`${rabbitUrl}/v1/devices`, { headers: { authorization: `Bearer ${input.deviceToken}`, accept: 'application/json' }, signal: signalOf() });
84
+ fleet = response.ok === true ? await response.json() : null;
85
+ } catch { fleet = null; }
86
+ if (fleet === null || !Array.isArray(fleet.devices)) return { ok: false, code: 'VERSION_UNVERIFIABLE', detail: { reason: 'the relay device list could not be read' } };
87
+ const byId = new Map(fleet.devices.map((device) => [device.machine_id, device]));
88
+ const skew = ordered.steps
89
+ .map((step) => ({ machine_id: step.machine_id, version: byId.get(step.machine_id)?.caps?.plugin_version ?? null }))
90
+ .filter((row) => row.version !== expected);
91
+ if (skew.length > 0) return { ok: false, code: 'VERSION_SKEW', detail: { expected, machines: skew } };
92
+ }
93
+ }
94
+
95
+ const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
96
+ const negotiation = negotiateFeatures(capabilities);
97
+ if (negotiation.ok !== true) {
98
+ return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
99
+ }
100
+
101
+ const dispatched = new Map(); // step_id -> task_id
102
+ const tasks = [];
103
+ for (const step of ordered.steps) {
104
+ const taskId = taskIdOf(plan.plan_sha256, step.step_id);
105
+ const dependsOn = (step.depends_on ?? []).map((dep) => dispatched.get(dep)).filter((id) => typeof id === 'string');
106
+ const body = {
107
+ task_id: taskId,
108
+ mode: 'broadcast',
109
+ executor_machine_id: step.machine_id,
110
+ command_argv: step.command_argv,
111
+ command_hash: step.command_hash,
112
+ base_commit: plan.base_commit,
113
+ plan_sha256: plan.plan_sha256,
114
+ approval_sha256: approval.approval_sha256,
115
+ step_id: step.step_id,
116
+ ...(dependsOn.length > 0 ? { depends_on: dependsOn } : {}),
117
+ ...(Array.isArray(step.write_scope) && step.write_scope.length > 0 ? { write: true, write_scope: step.write_scope } : {}),
118
+ ...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
119
+ ...(input?.includeOffline === false ? {} : { include_offline: true }),
120
+ };
121
+ const check = inspectControlMessage({ v: 1, type: 'joint.dispatch', body });
122
+ if (check.ok !== true) {
123
+ writeAudit(auditFile, { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: step.step_id, reason_code: check.code ?? 'CONTROL_REFUSED' });
124
+ return { ok: false, code: check.code ?? 'CONTROL_REFUSED', detail: check.detail ?? null, partial: tasks.length > 0, tasks };
125
+ }
126
+ let response;
127
+ try {
128
+ response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: JSON.stringify(body), signal: signalOf() });
129
+ } catch (error) {
130
+ writeAudit(auditFile, { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId, reason_code: 'RELAY_UNREACHABLE' });
131
+ return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error), partial: tasks.length > 0, tasks };
132
+ }
133
+ const doc = await response.json().catch(() => null);
134
+ if (response.status === 409 && doc?.error?.code === 'TASK_EXISTS') {
135
+ writeAudit(auditFile, { event: 'joint.dispatch.already', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: taskId });
136
+ dispatched.set(step.step_id, taskId);
137
+ tasks.push({ step_id: step.step_id, task_id: taskId, already: true, leases: [] });
138
+ continue;
139
+ }
140
+ if (response.ok !== true) {
141
+ 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
+ return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc, partial: tasks.length > 0, tasks };
143
+ }
144
+ const returnedTaskId = doc?.task_id ?? doc?.task?.task_id ?? taskId;
145
+ const leases = Array.isArray(doc?.leases) ? doc.leases : [];
146
+ const foreign = leases.filter((lease) => lease?.state !== 'observing' && lease?.machine_id !== step.machine_id);
147
+ if (foreign.length > 0) {
148
+ try {
149
+ await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(returnedTaskId)}/cancel`, {
150
+ method: 'POST', headers, body: JSON.stringify({ reason: 'SELECTION_NOT_HONOURED' }), signal: signalOf(),
151
+ });
152
+ } catch { /* the refusal below is still the answer */ }
153
+ writeAudit(auditFile, { event: 'joint.dispatch.not_honoured', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: returnedTaskId });
154
+ return {
155
+ ok: false,
156
+ code: 'SELECTION_NOT_HONOURED',
157
+ detail: { step_id: step.step_id, named: step.machine_id, leased: foreign.map((lease) => lease.machine_id), task_id: returnedTaskId },
158
+ partial: tasks.length > 0,
159
+ tasks,
160
+ };
161
+ }
162
+ writeAudit(auditFile, { event: 'joint.dispatch.sent', plan_sha256: plan.plan_sha256, step_id: step.step_id, task_id: returnedTaskId, machine_id: step.machine_id });
163
+ dispatched.set(step.step_id, returnedTaskId);
164
+ tasks.push({
165
+ step_id: step.step_id,
166
+ task_id: returnedTaskId,
167
+ leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
168
+ });
169
+ }
170
+ return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, tasks };
171
+ }
@@ -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,97 @@
1
+ /**
2
+ * S2-d: the joint-dev tool surface.
3
+ *
4
+ * Only the two inert halves live here: a plan can be created/read/listed, and a status can be read.
5
+ * Approval and dispatch stay in `w2m-joint` (operator CLI) and the settings page — a model can never
6
+ * sign an approval or send a command, which is the decision-2 boundary.
7
+ */
8
+ import { buildPlan } from './joint-dev/dispatch.mjs';
9
+ import { assertDispatchAllowed, listPlans, loadApproval, loadPlan, savePlan } from './joint-dev/plan-store.mjs';
10
+ import { loadOperatorKey } from './operator-key.mjs';
11
+
12
+ const json = (value) => JSON.stringify(value, null, 2);
13
+
14
+ export function registerJointTools(ctx, { defineTool, stateDir, machineName, output }) {
15
+ const requireStateDir = () => {
16
+ if (typeof stateDir === 'string' && stateDir !== '') return stateDir;
17
+ return null;
18
+ };
19
+
20
+ ctx.tools.register(defineTool({
21
+ name: 'w2m_joint_plan',
22
+ description:
23
+ 'Joint-dev plan store (metadata only). action=create builds a canonical plan from steps_json ' +
24
+ '(an array of {step_id, machine_id, command_argv, depends_on?, write_scope?}), action=list lists ' +
25
+ 'saved plans, action=get reads one. Nothing is approved or dispatched by this tool.',
26
+ parameters: {
27
+ action: { type: 'string', enum: ['create', 'list', 'get'], description: 'create | list | get (default list)' },
28
+ steps_json: { type: 'string', description: 'action=create: JSON array of steps' },
29
+ base_commit: { type: 'string', description: 'action=create: base commit the plan is anchored to' },
30
+ plan_sha256: { type: 'string', description: 'action=get: the 64-hex plan id' },
31
+ },
32
+ output: output ?? { type: 'json' },
33
+ async execute(args) {
34
+ const dir = requireStateDir();
35
+ if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE', detail: 'no stateDir configured for this plugin' });
36
+ const action = typeof args?.action === 'string' ? args.action : 'list';
37
+ if (action === 'list') return json({ ok: true, ...listPlans({ stateDir: dir }) });
38
+ if (action === 'get') {
39
+ const loaded = loadPlan({ stateDir: dir, planSha: String(args?.plan_sha256 ?? '') });
40
+ return json(loaded);
41
+ }
42
+ if (action === 'create') {
43
+ let steps;
44
+ try { steps = JSON.parse(String(args?.steps_json ?? '')); } catch { return json({ ok: false, code: 'PLAN_STEPS_INVALID' }); }
45
+ const built = buildPlan({ base_commit: typeof args?.base_commit === 'string' ? args.base_commit : undefined, steps });
46
+ if (built.ok !== true) return json(built);
47
+ const saved = savePlan({ stateDir: dir, plan: built.plan });
48
+ if (saved.ok !== true) return json(saved);
49
+ return json({
50
+ ok: true,
51
+ plan_sha256: built.plan.plan_sha256,
52
+ steps: built.plan.steps.length,
53
+ machines: [...new Set(built.plan.steps.map((step) => step.machine_id))],
54
+ path: saved.path,
55
+ reused: saved.reused,
56
+ initiator: machineName ?? null,
57
+ });
58
+ }
59
+ return json({ ok: false, code: 'PLAN_ACTION_INVALID', detail: { action } });
60
+ },
61
+ presentCall: () => ({ card: 'generic', title: 'joint plan', kind: 'read', rawInput: {} }),
62
+ }));
63
+
64
+ ctx.tools.register(defineTool({
65
+ name: 'w2m_joint_status',
66
+ description:
67
+ 'Read one joint-dev plan and its approval state (metadata only): approval strength, expiry, ' +
68
+ 'whether a signed approval allows dispatch, and the per-step machine/command hash. It never ' +
69
+ 'returns argv, output or file bytes, and it cannot approve or dispatch.',
70
+ parameters: {
71
+ plan_sha256: { type: 'string', description: 'the 64-hex plan id' },
72
+ },
73
+ output: output ?? { type: 'json' },
74
+ async execute(args) {
75
+ const dir = requireStateDir();
76
+ if (dir === null) return json({ ok: false, code: 'JOINT_STATE_UNAVAILABLE' });
77
+ const planSha = String(args?.plan_sha256 ?? '');
78
+ const plan = loadPlan({ stateDir: dir, planSha });
79
+ if (plan.ok !== true) return json(plan);
80
+ const key = loadOperatorKey();
81
+ const trusted = key.ok === true ? [key.key_id] : [];
82
+ const gate = assertDispatchAllowed({ stateDir: dir, planSha, requireSignature: true, trustedOperatorKeyIds: trusted });
83
+ const approval = loadApproval({ stateDir: dir, planSha, trustedOperatorKeyIds: trusted });
84
+ return json({
85
+ ok: true,
86
+ plan_sha256: planSha,
87
+ steps: plan.plan.steps.map((step) => ({ step_id: step.step_id, machine_id: step.machine_id, command_hash: step.command_hash })),
88
+ approval: approval.ok === true
89
+ ? { strength: approval.strength, approver: approval.approval.approver, expires_at: approval.approval.expires_at, key_id: approval.approval.operator_key?.key_id ?? null }
90
+ : { state: approval.code },
91
+ dispatch_allowed: gate.ok === true,
92
+ blocked_by: gate.ok === true ? null : gate.code,
93
+ });
94
+ },
95
+ presentCall: () => ({ card: 'generic', title: 'joint status', kind: 'read', rawInput: {} }),
96
+ }));
97
+ }