@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.6

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.
@@ -11,7 +11,7 @@
11
11
  * there is intentionally no snapshot/serialization hook yet.
12
12
  */
13
13
 
14
- import { createHash, randomBytes } from 'node:crypto';
14
+ import { createHash, randomBytes , verify as verifyBytes } from 'node:crypto';
15
15
 
16
16
  // v0.7.7: fleet scoping. Imported rather than inlined so the rule has one home with its own
17
17
  // exhaustive tests — three lines inside this object would be untestable without a live relay.
@@ -22,6 +22,9 @@ import { deviceFleetFields } from '../chatops/fleet-wiring.mjs';
22
22
  import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
23
23
  // v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
24
24
  import { planJoin } from '../chatops/fleet-join.mjs';
25
+ import { validateTaskDependencies } from './task-deps.mjs';
26
+ import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
27
+ import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
25
28
 
26
29
  export const PROTOCOL_VERSION = 1;
27
30
 
@@ -506,6 +509,14 @@ export class RabbitState {
506
509
  /** @type {{seq:number,event:object,target:string|null}[]} ring buffer, last N events */
507
510
  this.buffer = [];
508
511
  this.devices = new Map(); // machine_id -> device
512
+ /** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
513
+ this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
514
+ /** v1.2.5: read watermarks per session, per machine. */
515
+ this.readMarks = new Map(); // session_id -> Map<machine_id, {at_ms, message_id}>
516
+ /** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
517
+ this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
518
+ /** v1.2.5: read watermarks per session, per machine. */
519
+ this.readMarks = new Map(); // session_id -> Map<machine_id, {at_ms, message_id}>
509
520
  this.tokens = new Map(); // device_token -> machine_id
510
521
  this.tasks = new Map(); // task_id -> task
511
522
  this.results = new Map(); // instanceKey -> {envelope, machine_id, received_at_ms, ...}
@@ -716,6 +727,9 @@ export class RabbitState {
716
727
  requirements: t.requirements ?? {},
717
728
  compare_policy: t.compare_policy ?? {},
718
729
  halt: t.halt ?? 'never',
730
+ ...(t.mode === 'mapreduce' ? { reduce_argv: Array.isArray(t.reduce_argv) ? t.reduce_argv : null, reducer_machine_id: t.reducer_machine_id ?? null } : {}),
731
+ max_attempts: Number.isInteger(t.max_attempts) ? t.max_attempts : 1,
732
+ retry_on: Array.isArray(t.retry_on) ? t.retry_on : [],
719
733
  created_by: t.created_by ?? null,
720
734
  created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
721
735
  command_hash: t.command_hash ?? null,
@@ -737,6 +751,16 @@ export class RabbitState {
737
751
  this.tasks.set(task.task_id, task);
738
752
  return true;
739
753
  }
754
+ case 'member.role': {
755
+ /**
756
+ * v1.2.6: a role survives a restart. The ledger entry already existed (setMemberRole writes it); replaying it here is
757
+ * what stops "the relay restarted" from silently demoting every owner to the default worker.
758
+ */
759
+ if (typeof entry.machine_id !== 'string' || entry.machine_id === '') return false;
760
+ if (isMemberRole(entry.role) !== true) return false;
761
+ this.roles.set(entry.machine_id, entry.role);
762
+ return false;
763
+ }
740
764
  case 'lease.changed': {
741
765
  const task = this.tasks.get(entry.task_id);
742
766
  const lease = task?.leases.get(entry.machine_id);
@@ -1517,12 +1541,20 @@ export class RabbitState {
1517
1541
  // v0.4.0: refused BEFORE the task literal is built and before any offer is emitted, so a bad
1518
1542
  // value cannot leave a half-created task behind. `POST /v1/task` is all-or-nothing.
1519
1543
  const routing = RabbitState.validateTaskRouting(input);
1544
+ // v1.2.4: prerequisites are validated where the task is CREATED. A graph that only fails later puts the
1545
+ // refusal on a machine that did not create the problem.
1546
+ const deps = validateTaskDependencies({
1547
+ task_id: typeof input.task_id === 'string' ? input.task_id : null,
1548
+ depends_on: input.depends_on,
1549
+ tasksById: this.tasks,
1550
+ });
1551
+ if (deps.ok !== true) return { status: 'refused', refusal_reason: deps.code, detail: deps.detail ?? {} };
1520
1552
 
1521
1553
  const mode = input.mode ?? 'replicate';
1522
- if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline' && mode !== 'compose') {
1554
+ if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline' && mode !== 'compose' && mode !== 'mapreduce') {
1523
1555
  throw new ProtocolError(
1524
1556
  'BAD_REQUEST',
1525
- 'mode must be replicate|split|broadcast|pipeline|compose',
1557
+ 'mode must be replicate|split|broadcast|pipeline|compose|mapreduce',
1526
1558
  { mode },
1527
1559
  );
1528
1560
  }
@@ -1591,9 +1623,48 @@ export class RabbitState {
1591
1623
  }
1592
1624
  const indexTotal = Number.isInteger(input.index_total) ? input.index_total : 1;
1593
1625
  if (indexTotal < 1) throw new ProtocolError('BAD_REQUEST', 'index_total must be >= 1');
1626
+ /**
1627
+ * v1.2.4 mapreduce: the map half is ordinary dispatch; the reduce half is described here so the caller can run it as an
1628
+ * explicit second task once w2m_wait has verified every shard is present. Deliberately NOT a cascade: a relay that
1629
+ * launches the reduce itself would decide on a partial fleet, and the operator would see one task id for two executions.
1630
+ */
1631
+ let reduceArgv = null;
1632
+ let reducerMachineId = null;
1633
+ if (mode === 'mapreduce') {
1634
+ if (indexTotal !== 1) {
1635
+ throw new ProtocolError('BAD_REQUEST', 'mode=mapreduce maps the whole input on every machine, so index_total must be 1', { index_total: indexTotal });
1636
+ }
1637
+ const argv = input.reduce_argv;
1638
+ if (!Array.isArray(argv) || argv.length === 0 || argv.some((part) => typeof part !== 'string' || part === '')) {
1639
+ throw new ProtocolError('BAD_REQUEST', 'mode=mapreduce requires reduce_argv as a non-empty string[]', { reduce_argv: argv ?? null });
1640
+ }
1641
+ reduceArgv = [...argv];
1642
+ if (input.reducer_machine_id !== undefined && input.reducer_machine_id !== null && typeof input.reducer_machine_id !== 'string') {
1643
+ throw new ProtocolError('BAD_REQUEST', 'reducer_machine_id must be a machine id string', { reducer_machine_id: input.reducer_machine_id });
1644
+ }
1645
+ reducerMachineId = typeof input.reducer_machine_id === 'string' && input.reducer_machine_id !== '' ? input.reducer_machine_id : null;
1646
+ } else if (input.reduce_argv !== undefined || input.reducer_machine_id !== undefined) {
1647
+ throw new ProtocolError('BAD_REQUEST', 'reduce_argv and reducer_machine_id are only valid with mode=mapreduce', { mode });
1648
+ }
1594
1649
 
1595
1650
  const halt = input.halt ?? 'never';
1596
1651
  if (halt !== 'never' && halt !== 'now') throw new ProtocolError('BAD_REQUEST', 'halt must be never|now', { halt });
1652
+ /**
1653
+ * v1.2.4 retry policy: how many attempts the task may take in total, and which terminal conditions justify
1654
+ * another. Default one attempt and no retry, so every existing caller keeps at-most-once semantics.
1655
+ */
1656
+ const maxAttempts = Number.isInteger(input.max_attempts) ? input.max_attempts : 1;
1657
+ if (maxAttempts < 1 || maxAttempts > 5) {
1658
+ throw new ProtocolError('BAD_REQUEST', 'max_attempts must be an integer 1..5', { max_attempts: input.max_attempts });
1659
+ }
1660
+ const RETRYABLE = ['expired', 'failed', 'partial', 'timeout'];
1661
+ if (input.retry_on !== undefined && !Array.isArray(input.retry_on)) {
1662
+ throw new ProtocolError('BAD_REQUEST', 'retry_on must be an array of expired|failed|partial|timeout', { retry_on: input.retry_on });
1663
+ }
1664
+ const retryOn = Array.isArray(input.retry_on) ? [...new Set(input.retry_on)] : [];
1665
+ if (retryOn.some((reason) => typeof reason !== 'string' || !RETRYABLE.includes(reason))) {
1666
+ throw new ProtocolError('BAD_REQUEST', 'retry_on must list expired|failed|partial|timeout', { retry_on: input.retry_on, retryable: RETRYABLE });
1667
+ }
1597
1668
 
1598
1669
  const taskId = input.task_id ?? this.nextId();
1599
1670
  if (this.tasks.has(taskId)) {
@@ -1659,6 +1730,8 @@ export class RabbitState {
1659
1730
  // machine. Named explicitly so it travels.
1660
1731
  cwd_outside_project: input.cwd_outside_project === true,
1661
1732
  index_total: indexTotal,
1733
+ // v1.2.4: the reduce half travels with the task so a caller (and a revived relay) can read what was intended.
1734
+ ...(mode === 'mapreduce' ? { reduce_argv: reduceArgv, reducer_machine_id: reducerMachineId } : {}),
1662
1735
  timeout_ms: timeoutMs,
1663
1736
  write: input.write === true,
1664
1737
  write_scope: input.write_scope ?? [],
@@ -1673,6 +1746,14 @@ export class RabbitState {
1673
1746
  created_at_ms: now,
1674
1747
  command_hash: commandHash,
1675
1748
  attempt: 1,
1749
+ // v1.2.4: the policy travels with the task so a revived relay can still decide to retry after a restart.
1750
+ max_attempts: maxAttempts,
1751
+ retry_on: retryOn,
1752
+ // v1.2.4: prerequisites and an assignee travel with the task, because a graph the relay validated but did not
1753
+ // store would have to be re-derived later -- and a re-derivation that disagrees with the validation is exactly
1754
+ // the silent-drop failure this field-by-field literal exists to prevent.
1755
+ depends_on: deps.ok === true ? deps.depends_on : [],
1756
+ assignee: typeof input.assignee === 'string' && input.assignee !== '' ? input.assignee : null,
1676
1757
  leases: new Map(),
1677
1758
  cancelled: false,
1678
1759
  cancel_reason: null,
@@ -2144,6 +2225,54 @@ export class RabbitState {
2144
2225
  };
2145
2226
  }
2146
2227
 
2228
+ /** v1.2.6: the role this machine holds, for the permission matrix. Unknown means worker, never owner. */
2229
+ roleOf(machineId) {
2230
+ return typeof machineId === 'string' ? (this.roles.get(machineId) ?? null) : null;
2231
+ }
2232
+
2233
+ /** v1.2.5: the fleet's members, with the role each holds and when it joined. */
2234
+ listMembers() {
2235
+ return buildMembers({ devices: this.devices, roles: this.roles });
2236
+ }
2237
+
2238
+ /** v1.2.5: an operator decision about one machine. Refused by name, never silently ignored. */
2239
+ setMemberRole(machineId, role) {
2240
+ const result = applyMemberRole({ machineId, role, devices: this.devices, roles: this.roles });
2241
+ if (result.ok === true) {
2242
+ this.emit('notice', { level: 'info', code: 'MEMBER_ROLE_SET', message: `${machineId} is now ${role}`, machine_id: machineId });
2243
+ this._ledger({ type: 'member.role', machine_id: machineId, role });
2244
+ }
2245
+ return result;
2246
+ }
2247
+
2248
+ /**
2249
+ * v1.2.5: record that a machine has read a session up to a marker.
2250
+ *
2251
+ * Only forward: a stale marker arriving after a newer one must not move the watermark backwards, or the unread count would
2252
+ * grow for messages the machine has already seen.
2253
+ */
2254
+ markRead(sessionId, machineId, marker = {}) {
2255
+ if (typeof sessionId !== 'string' || sessionId === '') return { ok: false, code: 'BAD_SESSION' };
2256
+ if (typeof machineId !== 'string' || machineId === '') return { ok: false, code: 'BAD_MACHINE' };
2257
+ const atMs = Number.isFinite(marker.at_ms) ? Math.floor(marker.at_ms) : this.nowMs();
2258
+ const messageId = typeof marker.message_id === 'string' && marker.message_id !== '' ? marker.message_id : null;
2259
+ if (!this.readMarks.has(sessionId)) this.readMarks.set(sessionId, new Map());
2260
+ const perSession = this.readMarks.get(sessionId);
2261
+ const previous = perSession.get(machineId) ?? null;
2262
+ if (previous !== null && previous.at_ms > atMs) return { ok: true, mark: { ...previous }, advanced: false };
2263
+ const mark = { at_ms: atMs, message_id: messageId };
2264
+ perSession.set(machineId, mark);
2265
+ return { ok: true, mark: { ...mark }, advanced: true };
2266
+ }
2267
+
2268
+ /** v1.2.5: this machine's watermark for one session (null when it never read it). */
2269
+ readMark(sessionId, machineId) {
2270
+ const perSession = this.readMarks.get(sessionId) ?? null;
2271
+ if (perSession === null) return null;
2272
+ const mark = perSession.get(machineId) ?? null;
2273
+ return mark === null ? null : { ...mark };
2274
+ }
2275
+
2147
2276
  cancelTask(taskId, reason = 'cancelled_by_operator') {
2148
2277
  const task = this.requireTask(taskId);
2149
2278
  if (task.cancelled) return task;
@@ -2309,7 +2438,12 @@ export class RabbitState {
2309
2438
  task_id: task.task_id,
2310
2439
  machine_id: lease.machine_id,
2311
2440
  });
2312
- if (task.halt === 'now') {
2441
+ // v1.2.4: a caller that named "expired" in retry_on gets the hand-off halt=now always had, as long as it
2442
+ // has attempts left. Without the policy this stays book-keeping only -- the pre-1.2.4 behaviour.
2443
+ const retryExpired = task.halt === 'now'
2444
+ || (Number.isInteger(task.max_attempts) && task.attempt < task.max_attempts
2445
+ && Array.isArray(task.retry_on) && task.retry_on.includes('expired'));
2446
+ if (retryExpired) {
2313
2447
  task.attempt += 1;
2314
2448
  this._takeover(task, lease, nowMs);
2315
2449
  }
@@ -2513,6 +2647,42 @@ export class RabbitState {
2513
2647
  return { deduped: true, task_id: taskId, dedupe_key: dedupeKey, seq: prior.seq };
2514
2648
  }
2515
2649
 
2650
+ /**
2651
+ * v1.2.6: verify the envelope signature against the key this machine registered WHEN IT PAIRED.
2652
+ *
2653
+ * This is the difference between "the result matches the key it carries" and "the key belongs to this machine". The relay is
2654
+ * the place that can answer the second question, because pairing is the moment the operator (or the account) let the machine
2655
+ * in, and that is the only moment its signing key has a reason to be believed. A signature whose key differs from the
2656
+ * registered one is bad_signature even when it verifies against itself.
2657
+ */
2658
+ let signatureStatus = 'unsigned';
2659
+ const signature = envelope.signature ?? null;
2660
+ if (signature !== null) {
2661
+ signatureStatus = 'bad_signature';
2662
+ if (signature.alg !== 'ed25519') {
2663
+ signatureStatus = 'unsupported_algorithm';
2664
+ } else {
2665
+ const registeredKey = machineId === null ? null : (typeof this.devices.get(machineId)?.public_key === 'string' ? this.devices.get(machineId).public_key : null);
2666
+ if (registeredKey === null) {
2667
+ signatureStatus = 'unbound';
2668
+ } else if (signature.public_key !== registeredKey) {
2669
+ signatureStatus = 'bad_signature';
2670
+ } else {
2671
+ const key = publicKeyFromBase64(registeredKey);
2672
+ const payload = canonicalSignedPayload({
2673
+ task_id: envelope.task_id,
2674
+ argv_hash: envelope.command_hash,
2675
+ machine_id: envelope.machine_id,
2676
+ ts: signature.ts,
2677
+ });
2678
+ try {
2679
+ signatureStatus = key !== null && verifyBytes(null, Buffer.from(payload, 'utf8'), key, Buffer.from(String(signature.value ?? ''), 'base64'))
2680
+ ? 'signed' : 'bad_signature';
2681
+ } catch { signatureStatus = 'bad_signature'; }
2682
+ }
2683
+ }
2684
+ }
2685
+
2516
2686
  const now = this.nowMs();
2517
2687
  let envelopeShaOk = null;
2518
2688
  if (typeof envelope.envelope_sha256 === 'string') {
@@ -2531,6 +2701,7 @@ export class RabbitState {
2531
2701
  missing: [...new Set(missing)],
2532
2702
  problems,
2533
2703
  envelope_sha256_ok: envelopeShaOk,
2704
+ signature_status: signatureStatus,
2534
2705
  },
2535
2706
  };
2536
2707
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Task dependencies: a task may name prerequisites, and the graph is validated where the task is CREATED.
3
+ *
4
+ * ## Why the refusal belongs at enqueue time
5
+ *
6
+ * A dependency checked when a worker picks the task up puts the refusal on a machine that did not create the problem, at a
7
+ * time when the operator has long stopped looking. Everything here is a pure function over the tasks already known, so the
8
+ * relay can answer "is this graph sound" before it stores anything.
9
+ *
10
+ * ## What is refused, and why each one is a refusal rather than a warning
11
+ *
12
+ * - a dependency on a task that does not exist: it can never complete, so the task can never be ready;
13
+ * - a self-dependency and any cycle: no topological order exists, so "ready" would never arrive;
14
+ * - a duplicate id in the list: the same edge stated twice is not two edges, and accepting it would make the stored list
15
+ * disagree with the graph it describes.
16
+ */
17
+ export const TASK_DEPS_REFUSAL = Object.freeze({
18
+ DEPENDS_ON_NOT_ARRAY: 'TASK_DEPENDS_ON_NOT_ARRAY',
19
+ DEPENDENCY_UNKNOWN: 'TASK_DEPENDENCY_UNKNOWN',
20
+ DEPENDENCY_SELF: 'TASK_DEPENDENCY_SELF',
21
+ DEPENDENCY_DUPLICATE: 'TASK_DEPENDENCY_DUPLICATE',
22
+ DEPENDENCY_CYCLE: 'TASK_DEPENDENCY_CYCLE',
23
+ MAX_DEPENDENCIES: 'TASK_DEPENDENCY_TOO_MANY',
24
+ });
25
+
26
+ /** A cap exists so a hostile caller cannot make the cycle check quadratic without bound. */
27
+ export const MAX_DEPENDENCIES = 32;
28
+
29
+ /**
30
+ * Walk the ancestors of a task id and report whether a new edge would close a cycle.
31
+ *
32
+ * @param {string} from the dependency being added
33
+ * @param {string} target the task being created
34
+ * @param {Map<string, object>} tasksById
35
+ * @returns {string[]|null} the cycle path when one exists, null otherwise
36
+ */
37
+ export function wouldCycle(from, target, tasksById) {
38
+ const seen = new Set();
39
+ const stack = [from];
40
+ while (stack.length > 0) {
41
+ const id = stack.pop();
42
+ if (id === target) return [target, from];
43
+ if (seen.has(id)) continue;
44
+ seen.add(id);
45
+ const task = tasksById.get(id) ?? null;
46
+ const deps = Array.isArray(task?.depends_on) ? task.depends_on : [];
47
+ for (const dep of deps) stack.push(dep);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * @param {{task_id?: string|null, depends_on?: unknown, tasksById?: Map<string, object>}} input
54
+ * @returns {{ok: true, depends_on: string[]} | {ok: false, code: string, detail?: object}}
55
+ */
56
+ export function validateTaskDependencies({ task_id = null, depends_on = null, tasksById = new Map() } = {}) {
57
+ if (depends_on === undefined || depends_on === null) return { ok: true, depends_on: [] };
58
+ if (!Array.isArray(depends_on)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDS_ON_NOT_ARRAY };
59
+ if (depends_on.length > MAX_DEPENDENCIES) {
60
+ return { ok: false, code: TASK_DEPS_REFUSAL.MAX_DEPENDENCIES, detail: { count: depends_on.length, max: MAX_DEPENDENCIES } };
61
+ }
62
+ const list = [];
63
+ for (const dep of depends_on) {
64
+ if (typeof dep !== 'string' || dep === '') {
65
+ return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_UNKNOWN, detail: { dependency: dep } };
66
+ }
67
+ if (task_id !== null && dep === task_id) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_SELF, detail: { task_id } };
68
+ if (list.includes(dep)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_DUPLICATE, detail: { dependency: dep } };
69
+ if (!tasksById.has(dep)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_UNKNOWN, detail: { dependency: dep } };
70
+ const cycle = task_id === null ? null : wouldCycle(dep, task_id, tasksById);
71
+ if (cycle !== null) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_CYCLE, detail: { path: cycle } };
72
+ list.push(dep);
73
+ }
74
+ return { ok: true, depends_on: list };
75
+ }
76
+
77
+ /**
78
+ * What a task may do, given its prerequisites' states.
79
+ *
80
+ * @param {{depends_on?: string[], tasksById?: Map<string, object>}} input
81
+ * @returns {'ready'|'blocked'|'waiting'}
82
+ */
83
+ export function readinessOf({ depends_on = [], tasksById = new Map() } = {}) {
84
+ let waiting = false;
85
+ for (const dep of depends_on) {
86
+ const task = tasksById.get(dep) ?? null;
87
+ const status = typeof task?.status === 'string' ? task.status : 'unknown';
88
+ if (status === 'failed' || status === 'cancelled' || status === 'refused') return 'blocked';
89
+ if (status !== 'completed' && status !== 'done') waiting = true;
90
+ }
91
+ return waiting ? 'waiting' : 'ready';
92
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The fleet chat's end-to-end encryption.
3
+ *
4
+ * ## Why the relay never sees the key
5
+ *
6
+ * The group key is HKDF over the fleet key, and the fleet key is derived from the account identity on each machine -- it never
7
+ * travels to the relay. So the relay stores ciphertext it cannot open, and the honest consequence is written down rather than
8
+ * discovered later: the relay cannot search message content, and the quota it enforces is measured on ciphertext length.
9
+ *
10
+ * ## Why the metadata is next to the ciphertext, not inside it
11
+ *
12
+ * `{v, alg, iv, tag}` has to be readable by every client of every version, including one that cannot decrypt. Keeping it in a
13
+ * plain field means a reader can say "encrypted with aes-256-gcm, version 1" even when it has no key, instead of guessing from
14
+ * random-looking text.
15
+ *
16
+ * ## Backward compatibility, both directions
17
+ *
18
+ * A message with no `enc` field is plaintext and stays readable. A reader with no key keeps the ciphertext and marks it
19
+ * `encrypted: true` rather than throwing: an unreadable message must not break a whole page.
20
+ */
21
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
22
+
23
+ export const CHAT_CIPHER = Object.freeze({ version: 1, alg: 'aes-256-gcm' });
24
+ const KEY_BYTES = 32;
25
+ const IV_BYTES = 12;
26
+
27
+ /**
28
+ * Turn the fleet key into the chat group key. Deterministic: every machine in the fleet derives the same key from the same
29
+ * fleet key, which is what makes a message encrypted on one machine readable on another.
30
+ *
31
+ * @param {string} fleetKey base64 (the usual shape) or a passphrase
32
+ * @returns {Buffer|null}
33
+ */
34
+ export function deriveChatKey(fleetKey) {
35
+ if (typeof fleetKey !== 'string' || fleetKey.trim() === '') return null;
36
+ try {
37
+ let material = null;
38
+ try {
39
+ const decoded = Buffer.from(fleetKey.trim(), 'base64');
40
+ material = decoded.length >= 16 ? decoded : Buffer.from(fleetKey.trim(), 'utf8');
41
+ } catch {
42
+ material = Buffer.from(fleetKey.trim(), 'utf8');
43
+ }
44
+ return Buffer.from(hkdfSync('sha256', material, Buffer.from('w2m-fleet-chat-salt'), Buffer.from('fleet-chat-v1'), KEY_BYTES));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @param {Buffer|null} key
52
+ * @param {string} text
53
+ * @returns {{ok: true, text: string, enc: {v: number, alg: string, iv: string, tag: string}} | {ok: false, code: string}}
54
+ */
55
+ export function encryptChatText(key, text) {
56
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
57
+ if (typeof text !== 'string' || text === '') return { ok: false, code: 'CHAT_EMPTY_TEXT' };
58
+ const iv = randomBytes(IV_BYTES);
59
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
60
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(text, 'utf8')), cipher.final()]);
61
+ const tag = cipher.getAuthTag();
62
+ return {
63
+ ok: true,
64
+ text: ciphertext.toString('base64'),
65
+ enc: { v: CHAT_CIPHER.version, alg: CHAT_CIPHER.alg, iv: iv.toString('base64'), tag: tag.toString('base64') },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {Buffer|null} key
71
+ * @param {{text?: string, enc?: object|null}} message
72
+ * @returns {{ok: true, text: string} | {ok: false, code: string}}
73
+ */
74
+ export function decryptChatMessage(key, message = {}) {
75
+ const enc = message?.enc ?? null;
76
+ if (enc === null || enc === undefined) {
77
+ // Plaintext: the pre-encryption shape, still readable by everyone.
78
+ return typeof message?.text === 'string' ? { ok: true, text: message.text } : { ok: false, code: 'CHAT_TEXT_MISSING' };
79
+ }
80
+ if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
81
+ if (enc.alg !== CHAT_CIPHER.alg || enc.v !== CHAT_CIPHER.version) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
82
+ try {
83
+ const iv = Buffer.from(String(enc.iv ?? ''), 'base64');
84
+ const tag = Buffer.from(String(enc.tag ?? ''), 'base64');
85
+ if (iv.length !== IV_BYTES || tag.length !== 16) return { ok: false, code: 'CHAT_BAD_ENCRYPTION' };
86
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
87
+ decipher.setAuthTag(tag);
88
+ const plain = Buffer.concat([decipher.update(Buffer.from(String(message.text ?? ''), 'base64')), decipher.final()]);
89
+ return { ok: true, text: plain.toString('utf8') };
90
+ } catch {
91
+ return { ok: false, code: 'CHAT_DECRYPT_FAILED' };
92
+ }
93
+ }
94
+
95
+ /** True when a message carries the encryption metadata, whatever its text looks like. */
96
+ export function isEncrypted(message) {
97
+ return message?.enc !== null && message?.enc !== undefined && typeof message.enc === 'object';
98
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Data flow across the fleet: map results that are too big to return inline travel as blobs, and the reduce step names
3
+ * exactly which blob it read.
4
+ *
5
+ * ## Why a plan instead of a pipe
6
+ *
7
+ * Machines do not send data to each other directly: the relay is the only channel every machine can reach, and it already
8
+ * stores content-addressed blobs. So the shape is map -> PUT blob -> reduce by sha256. The decisions worth testing are the
9
+ * boundaries: when an output is inline, when it becomes a blob, and what happens when a blob a reduce depends on is not
10
+ * there. A pipeline that silently drops a shard is worse than one that fails.
11
+ */
12
+
13
+ /** Stage stdout at or below this travels inline in the result envelope. */
14
+ export const MAX_INLINE_BYTES = 64 * 1024;
15
+
16
+ /** Past this a stage output is not a result any more: it is a data problem the operator has to see. */
17
+ export const HARD_CAP_BYTES = 16 * 1024 * 1024;
18
+
19
+ /**
20
+ * Decide how one machine's stage output travels.
21
+ *
22
+ * @param {{machineId?: string, stageIndex?: number, stdoutBytes?: number}} input
23
+ * @returns {{ok: true, mode: 'inline'|'blob', stdoutBytes: number} | {ok: false, code: string, detail: object}}
24
+ */
25
+ export function planStageOutput(input = {}) {
26
+ const machineId = typeof input.machineId === 'string' && input.machineId !== '' ? input.machineId : null;
27
+ if (machineId === null) return { ok: false, code: 'STAGE_MACHINE_MISSING', detail: {} };
28
+ const stdoutBytes = Number.isFinite(input.stdoutBytes) ? Math.max(0, Math.floor(input.stdoutBytes)) : 0;
29
+ if (stdoutBytes > HARD_CAP_BYTES) {
30
+ return { ok: false, code: 'STAGE_OUTPUT_TOO_LARGE', detail: { machineId, stdoutBytes, hardCapBytes: HARD_CAP_BYTES } };
31
+ }
32
+ return { ok: true, mode: stdoutBytes <= MAX_INLINE_BYTES ? 'inline' : 'blob', stdoutBytes };
33
+ }
34
+
35
+ const SHA256 = /^[0-9a-f]{64}$/;
36
+
37
+ /**
38
+ * Build the reduce input list from the map results.
39
+ *
40
+ * Every map machine must contribute exactly one entry: inline text is carried as-is, a blob result must name a sha256.
41
+ * A missing or malformed hash is refused by name -- the reduce step must never run on a partial fleet and call the answer
42
+ * complete.
43
+ *
44
+ * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
45
+ * reducerMachineId?: string}} input
46
+ * @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
47
+ * {ok: false, code: string, detail: object}}
48
+ */
49
+ export function buildReducePlan(input = {}) {
50
+ const reducer = typeof input.reducerMachineId === 'string' && input.reducerMachineId !== '' ? input.reducerMachineId : null;
51
+ if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
52
+ const results = Array.isArray(input.results) ? input.results : null;
53
+ if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
54
+
55
+ const seen = new Set();
56
+ const inputs = [];
57
+ for (const result of results) {
58
+ const machineId = typeof result?.machine_id === 'string' && result.machine_id !== '' ? result.machine_id : null;
59
+ if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
60
+ if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
61
+ seen.add(machineId);
62
+ const mode = result?.mode === 'blob' ? 'blob' : 'inline';
63
+ if (mode === 'blob') {
64
+ const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
65
+ if (!SHA256.test(sha)) return { ok: false, code: 'BLOB_MISSING', detail: { machineId, sha256: result?.blob_sha256 ?? null } };
66
+ inputs.push({ machine_id: machineId, mode, sha256: sha });
67
+ } else {
68
+ inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
69
+ }
70
+ }
71
+ return { ok: true, reducerMachineId: reducer, inputs };
72
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Envelope signatures: what a machine signed, and how a reader checks it.
3
+ *
4
+ * ## Why the payload is exactly four fields
5
+ *
6
+ * A signature over "the whole envelope" is impossible to verify reproducibly: the envelope carries hashes, timestamps and
7
+ * transport facts that change between the signing machine and the reader. So the machine signs the four facts that identify
8
+ * THE COMMAND: task id, argv hash (the envelope's command_hash), machine id, and when it signed. Everything else in the
9
+ * envelope is covered indirectly through those, and a reader verifies exactly this tuple.
10
+ *
11
+ * ## Why verification returns a status rather than throwing
12
+ *
13
+ * "unsigned" is not an error -- a machine running an older build, or one configured without a device key, is a fact to report.
14
+ * "bad_signature" is the interesting case: the data does not match the signature, which is exactly what tampering looks like.
15
+ * A caller must see the difference, so both are values.
16
+ */
17
+ import { createPublicKey, verify } from 'node:crypto';
18
+
19
+ /** The DER prefix for an Ed25519 SubjectPublicKeyInfo; the 32 raw key bytes follow. */
20
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
21
+
22
+ /**
23
+ * The canonical bytes a machine signs. Sorted keys, no whitespace, so the same tuple always produces the same bytes.
24
+ *
25
+ * @param {{task_id: string, argv_hash: string, machine_id: string, ts: string}} input
26
+ * @returns {string}
27
+ */
28
+ export function canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts }) {
29
+ return JSON.stringify({
30
+ argv_hash: String(argvHash ?? ''),
31
+ machine_id: String(machineId ?? ''),
32
+ task_id: String(taskId ?? ''),
33
+ ts: String(ts ?? ''),
34
+ });
35
+ }
36
+
37
+ /**
38
+ * @param {string|null} publicKeyBase64
39
+ * @returns {import('node:crypto').KeyObject|null}
40
+ */
41
+ export function publicKeyFromBase64(publicKeyBase64) {
42
+ if (typeof publicKeyBase64 !== 'string' || publicKeyBase64 === '') return null;
43
+ try {
44
+ const raw = Buffer.from(publicKeyBase64, 'base64');
45
+ if (raw.length !== 32) return null;
46
+ return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {{signature?: object|null, task_id?: string, argv_hash?: string, machine_id?: string, ts?: string}} input
54
+ * @returns {'signed'|'unsigned'|'bad_signature'|'unsupported_algorithm'}
55
+ */
56
+ export function verifyEnvelopeSignature({ signature = null, task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts } = {}) {
57
+ if (signature === null || signature === undefined || typeof signature !== 'object') return 'unsigned';
58
+ if (signature.alg !== 'ed25519') return 'unsupported_algorithm';
59
+ if (typeof signature.value !== 'string' || signature.value === '') return 'bad_signature';
60
+ const key = publicKeyFromBase64(signature.public_key);
61
+ if (key === null) return 'bad_signature';
62
+ const payload = canonicalSignedPayload({ task_id: taskId, argv_hash: argvHash, machine_id: machineId, ts: signature.ts ?? ts });
63
+ try {
64
+ const ok = verify(null, Buffer.from(payload, 'utf8'), key, Buffer.from(signature.value, 'base64'));
65
+ return ok ? 'signed' : 'bad_signature';
66
+ } catch {
67
+ return 'bad_signature';
68
+ }
69
+ }