@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +3 -3
- package/bin/w2m-joint.mjs +36 -5
- package/bin/w2m-scrub.mjs +78 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
- package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
- package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
- package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
- package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
- package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
- package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
- package/lib/client.js +474 -368
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +6 -1
- package/src/agent/sinks/dir-sink.mjs +59 -13
- package/src/plugin/fleet-chat-route.mjs +7 -1
- package/src/plugin/joint-dev/audit-file.mjs +70 -37
- package/src/plugin/joint-dev/merge-engine.mjs +102 -0
- package/src/plugin/joint-dev/plan-store.mjs +117 -32
- package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
- package/src/plugin/operator-key.mjs +73 -0
- package/src/plugin/operator-token-route.mjs +71 -0
- package/src/plugin/tools-joint.mjs +97 -0
- package/src/plugin/tools.mjs +8 -3
- package/src/relay/fleet-messages.mjs +22 -0
- package/src/relay/offer-journal.mjs +71 -0
- package/src/relay/p0-projection.mjs +10 -0
- package/src/relay/report.mjs +40 -2
- package/src/relay/scrub.mjs +94 -81
- package/src/relay/server.mjs +2043 -2038
- package/src/relay/state.mjs +3351 -3300
- package/src/util/artifact-ref.mjs +88 -0
- 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 +
|
|
8
|
-
* ·
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
+
const sha = name.slice(0, -5);
|
|
90
|
+
if (!PLAN_SHA.test(sha)) continue;
|
|
76
91
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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(
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
return { ok: true, approval:
|
|
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
|
-
|
|
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: '
|
|
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(
|
|
127
|
-
|
|
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
|
-
/**
|
|
131
|
-
|
|
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
|
-
|
|
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
|
+
}
|