@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +184 -0
- package/README.md +462 -462
- package/codex/mcp-servers.toml +1 -1
- package/package.json +14 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +162 -4
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/ask-store.mjs +167 -0
- package/src/plugin/schema-spec.mjs +33 -0
- package/src/plugin/task-graph.mjs +147 -0
- package/src/plugin/tools.mjs +7748 -7385
- package/src/plugin/turn.mjs +41 -0
- package/src/relay/fleet-members.mjs +78 -0
- package/src/relay/fleet-messages.mjs +241 -173
- package/src/relay/report.mjs +5 -0
- package/src/relay/server.mjs +1596 -1498
- package/src/relay/state.mjs +175 -4
- package/src/relay/task-deps.mjs +92 -0
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/dataflow.mjs +72 -0
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
package/src/relay/state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|