@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.7
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 +121 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +4436 -4360
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +284 -40
- package/src/relay/fleet-messages.mjs +23 -0
- package/src/relay/report.mjs +16 -0
- package/src/relay/server.mjs +33 -1
- package/src/relay/state.mjs +157 -2
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/dataflow.mjs +24 -1
- 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.
|
|
@@ -23,7 +23,8 @@ 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
25
|
import { validateTaskDependencies } from './task-deps.mjs';
|
|
26
|
-
import { buildMembers, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
26
|
+
import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
27
|
+
import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
|
|
27
28
|
|
|
28
29
|
export const PROTOCOL_VERSION = 1;
|
|
29
30
|
|
|
@@ -733,6 +734,9 @@ export class RabbitState {
|
|
|
733
734
|
created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
|
|
734
735
|
command_hash: t.command_hash ?? null,
|
|
735
736
|
attempt: t.attempt ?? 1,
|
|
737
|
+
// v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
|
|
738
|
+
depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
|
|
739
|
+
assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
|
|
736
740
|
leases: new Map(),
|
|
737
741
|
cancelled: false,
|
|
738
742
|
cancel_reason: null,
|
|
@@ -750,6 +754,16 @@ export class RabbitState {
|
|
|
750
754
|
this.tasks.set(task.task_id, task);
|
|
751
755
|
return true;
|
|
752
756
|
}
|
|
757
|
+
case 'member.role': {
|
|
758
|
+
/**
|
|
759
|
+
* v1.2.6: a role survives a restart. The ledger entry already existed (setMemberRole writes it); replaying it here is
|
|
760
|
+
* what stops "the relay restarted" from silently demoting every owner to the default worker.
|
|
761
|
+
*/
|
|
762
|
+
if (typeof entry.machine_id !== 'string' || entry.machine_id === '') return false;
|
|
763
|
+
if (isMemberRole(entry.role) !== true) return false;
|
|
764
|
+
this.roles.set(entry.machine_id, entry.role);
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
753
767
|
case 'lease.changed': {
|
|
754
768
|
const task = this.tasks.get(entry.task_id);
|
|
755
769
|
const lease = task?.leases.get(entry.machine_id);
|
|
@@ -1906,6 +1920,31 @@ export class RabbitState {
|
|
|
1906
1920
|
if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
|
|
1907
1921
|
throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
|
|
1908
1922
|
}
|
|
1923
|
+
/**
|
|
1924
|
+
* v1.2.7: a dependency that is not settled means this task is NOT dispatched yet. Its leases are recorded as `waiting`,
|
|
1925
|
+
* which the sweeper ignores and redelivery skips, and `_releaseDependents` moves them to offers when the graph allows it.
|
|
1926
|
+
*/
|
|
1927
|
+
const dependencyStates = (task.depends_on ?? []).map((depId) => {
|
|
1928
|
+
const dep = this.tasks.get(depId);
|
|
1929
|
+
if (dep === undefined) return 'unknown';
|
|
1930
|
+
if (dep.cancelled === true) return 'cancelled';
|
|
1931
|
+
return this._taskSettled(dep) === true ? 'completed' : 'waiting';
|
|
1932
|
+
});
|
|
1933
|
+
const graphBlocked = dependencyStates.some((state) => state === 'cancelled' || state === 'unknown');
|
|
1934
|
+
const graphReady = graphBlocked !== true && dependencyStates.every((state) => state === 'completed');
|
|
1935
|
+
const waitingOn = graphReady === true ? [] : (task.depends_on ?? []);
|
|
1936
|
+
/**
|
|
1937
|
+
* v1.2.7: an assignee is a restriction, not a label. Only that machine gets a lease; another machine cannot accidentally
|
|
1938
|
+
* take work that was meant for one host. An unknown assignee is refused by name rather than producing a task nobody holds.
|
|
1939
|
+
*/
|
|
1940
|
+
if (task.assignee !== null && !this.devices.has(task.assignee)) {
|
|
1941
|
+
throw new ProtocolError('BAD_REQUEST', 'assignee is not a known device on this relay', { assignee: task.assignee });
|
|
1942
|
+
}
|
|
1943
|
+
if (task.assignee !== null) {
|
|
1944
|
+
const narrowed = targets.filter((device) => device.machine_id === task.assignee);
|
|
1945
|
+
targets.length = 0;
|
|
1946
|
+
targets.push(...narrowed);
|
|
1947
|
+
}
|
|
1909
1948
|
let lastSeq = this.seq;
|
|
1910
1949
|
targets.forEach((device, i) => {
|
|
1911
1950
|
const index = mode === 'replicate' ? 0 : i % indexTotal;
|
|
@@ -1939,6 +1978,12 @@ export class RabbitState {
|
|
|
1939
1978
|
task.leases.set(device.machine_id, lease);
|
|
1940
1979
|
return;
|
|
1941
1980
|
}
|
|
1981
|
+
if (graphReady !== true) {
|
|
1982
|
+
lease.state = 'waiting';
|
|
1983
|
+
lease.waiting_on = waitingOn;
|
|
1984
|
+
task.leases.set(device.machine_id, lease);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1942
1987
|
const gate = gateMachine(task, device);
|
|
1943
1988
|
if (gate) {
|
|
1944
1989
|
lease.state = 'refused';
|
|
@@ -1991,6 +2036,9 @@ export class RabbitState {
|
|
|
1991
2036
|
attempt: task.attempt,
|
|
1992
2037
|
deadline_ms: task.deadline_ms,
|
|
1993
2038
|
// v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
|
|
2039
|
+
// v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
|
|
2040
|
+
depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
2041
|
+
assignee: task.assignee ?? null,
|
|
1994
2042
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
1995
2043
|
p2p: task.p2p ?? null,
|
|
1996
2044
|
},
|
|
@@ -2200,6 +2248,47 @@ export class RabbitState {
|
|
|
2200
2248
|
.map((t) => this.taskSummary(t));
|
|
2201
2249
|
}
|
|
2202
2250
|
|
|
2251
|
+
/** v1.2.7: a task is settled when no lease can still produce a result. Dependencies wait for that, not for a wording. */
|
|
2252
|
+
_taskSettled(task) {
|
|
2253
|
+
for (const lease of task.leases.values()) if (ACTIVE_LEASE_STATES.includes(lease.state)) return false;
|
|
2254
|
+
return true;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* v1.2.7: release tasks whose prerequisites are all settled.
|
|
2259
|
+
*
|
|
2260
|
+
* Called after a result is stored: a dependency that finished must actually move its dependents from `waiting` to an offer.
|
|
2261
|
+
* Without this the graph would be validated, stored, and then never scheduled -- the exact "validated but not enforced" gap.
|
|
2262
|
+
*/
|
|
2263
|
+
_releaseDependents(completedTaskId = null) {
|
|
2264
|
+
const released = [];
|
|
2265
|
+
for (const candidate of this.tasks.values()) {
|
|
2266
|
+
if (!Array.isArray(candidate.depends_on) || candidate.depends_on.length === 0) continue;
|
|
2267
|
+
if (completedTaskId !== null && !candidate.depends_on.includes(completedTaskId)) continue;
|
|
2268
|
+
const waiting = [...candidate.leases.values()].filter((lease) => lease.state === 'waiting');
|
|
2269
|
+
if (waiting.length === 0) continue;
|
|
2270
|
+
let ready = true;
|
|
2271
|
+
for (const depId of candidate.depends_on) {
|
|
2272
|
+
const dep = this.tasks.get(depId);
|
|
2273
|
+
if (dep === undefined) { ready = false; break; }
|
|
2274
|
+
if (dep.cancelled === true) { ready = false; break; }
|
|
2275
|
+
if (this._taskSettled(dep) !== true) { ready = false; break; }
|
|
2276
|
+
}
|
|
2277
|
+
if (ready !== true) continue;
|
|
2278
|
+
for (const lease of waiting) {
|
|
2279
|
+
lease.state = 'queued';
|
|
2280
|
+
lease.claimed_at_ms = this.nowMs();
|
|
2281
|
+
lease.lease_until_ms = this.nowMs() + this.leaseWindowMs();
|
|
2282
|
+
const device = this.devices.get(lease.machine_id);
|
|
2283
|
+
if (device === undefined) continue;
|
|
2284
|
+
this.emitOffer(candidate, lease);
|
|
2285
|
+
}
|
|
2286
|
+
this.emit('notice', { level: 'info', code: 'TASK_READY', message: candidate.task_id + ' is ready: dependencies settled', task_id: candidate.task_id });
|
|
2287
|
+
released.push(candidate.task_id);
|
|
2288
|
+
}
|
|
2289
|
+
return released;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2203
2292
|
taskSummary(task) {
|
|
2204
2293
|
return {
|
|
2205
2294
|
task_id: task.task_id,
|
|
@@ -2211,9 +2300,32 @@ export class RabbitState {
|
|
|
2211
2300
|
degraded: task.degraded,
|
|
2212
2301
|
leases: [...task.leases.values()].map((l) => l.machine_id),
|
|
2213
2302
|
lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
|
|
2303
|
+
/**
|
|
2304
|
+
* v1.2.7: the state the scheduler is actually in, derived here rather than by each reader.
|
|
2305
|
+
*
|
|
2306
|
+
* Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
|
|
2307
|
+
* map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
|
|
2308
|
+
*/
|
|
2309
|
+
status: (() => {
|
|
2310
|
+
if (task.cancelled === true) return 'cancelled';
|
|
2311
|
+
const leases = [...task.leases.values()];
|
|
2312
|
+
if (leases.some((lease) => lease.state === 'waiting')) return 'waiting';
|
|
2313
|
+
if (leases.some((lease) => ACTIVE_LEASE_STATES.includes(lease.state))) return 'active';
|
|
2314
|
+
const results = [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id);
|
|
2315
|
+
if (results.length > 0 && results.every((record) => ['failed', 'crashed', 'refused', 'timeout'].includes(record?.envelope?.status))) return 'failed';
|
|
2316
|
+
return this._taskSettled(task) === true ? 'done' : 'unknown';
|
|
2317
|
+
})(),
|
|
2318
|
+
depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
2319
|
+
assignee: task.assignee ?? null,
|
|
2320
|
+
waiting_on: [...task.leases.values()].some((l) => l.state === 'waiting') ? (Array.isArray(task.depends_on) ? task.depends_on : []) : [],
|
|
2214
2321
|
};
|
|
2215
2322
|
}
|
|
2216
2323
|
|
|
2324
|
+
/** v1.2.6: the role this machine holds, for the permission matrix. Unknown means worker, never owner. */
|
|
2325
|
+
roleOf(machineId) {
|
|
2326
|
+
return typeof machineId === 'string' ? (this.roles.get(machineId) ?? null) : null;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2217
2329
|
/** v1.2.5: the fleet's members, with the role each holds and when it joined. */
|
|
2218
2330
|
listMembers() {
|
|
2219
2331
|
return buildMembers({ devices: this.devices, roles: this.roles });
|
|
@@ -2434,6 +2546,9 @@ export class RabbitState {
|
|
|
2434
2546
|
}
|
|
2435
2547
|
if (changed) task.degraded = 'partial';
|
|
2436
2548
|
}
|
|
2549
|
+
// v1.2.7: a lease that expires (or a machine that refuses) is a settlement too -- the dependent graph must move.
|
|
2550
|
+
this._releaseDependents();
|
|
2551
|
+
|
|
2437
2552
|
return expired;
|
|
2438
2553
|
}
|
|
2439
2554
|
|
|
@@ -2631,6 +2746,42 @@ export class RabbitState {
|
|
|
2631
2746
|
return { deduped: true, task_id: taskId, dedupe_key: dedupeKey, seq: prior.seq };
|
|
2632
2747
|
}
|
|
2633
2748
|
|
|
2749
|
+
/**
|
|
2750
|
+
* v1.2.6: verify the envelope signature against the key this machine registered WHEN IT PAIRED.
|
|
2751
|
+
*
|
|
2752
|
+
* This is the difference between "the result matches the key it carries" and "the key belongs to this machine". The relay is
|
|
2753
|
+
* the place that can answer the second question, because pairing is the moment the operator (or the account) let the machine
|
|
2754
|
+
* in, and that is the only moment its signing key has a reason to be believed. A signature whose key differs from the
|
|
2755
|
+
* registered one is bad_signature even when it verifies against itself.
|
|
2756
|
+
*/
|
|
2757
|
+
let signatureStatus = 'unsigned';
|
|
2758
|
+
const signature = envelope.signature ?? null;
|
|
2759
|
+
if (signature !== null) {
|
|
2760
|
+
signatureStatus = 'bad_signature';
|
|
2761
|
+
if (signature.alg !== 'ed25519') {
|
|
2762
|
+
signatureStatus = 'unsupported_algorithm';
|
|
2763
|
+
} else {
|
|
2764
|
+
const registeredKey = machineId === null ? null : (typeof this.devices.get(machineId)?.public_key === 'string' ? this.devices.get(machineId).public_key : null);
|
|
2765
|
+
if (registeredKey === null) {
|
|
2766
|
+
signatureStatus = 'unbound';
|
|
2767
|
+
} else if (signature.public_key !== registeredKey) {
|
|
2768
|
+
signatureStatus = 'bad_signature';
|
|
2769
|
+
} else {
|
|
2770
|
+
const key = publicKeyFromBase64(registeredKey);
|
|
2771
|
+
const payload = canonicalSignedPayload({
|
|
2772
|
+
task_id: envelope.task_id,
|
|
2773
|
+
argv_hash: envelope.command_hash,
|
|
2774
|
+
machine_id: envelope.machine_id,
|
|
2775
|
+
ts: signature.ts,
|
|
2776
|
+
});
|
|
2777
|
+
try {
|
|
2778
|
+
signatureStatus = key !== null && verifyBytes(null, Buffer.from(payload, 'utf8'), key, Buffer.from(String(signature.value ?? ''), 'base64'))
|
|
2779
|
+
? 'signed' : 'bad_signature';
|
|
2780
|
+
} catch { signatureStatus = 'bad_signature'; }
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2634
2785
|
const now = this.nowMs();
|
|
2635
2786
|
let envelopeShaOk = null;
|
|
2636
2787
|
if (typeof envelope.envelope_sha256 === 'string') {
|
|
@@ -2649,6 +2800,7 @@ export class RabbitState {
|
|
|
2649
2800
|
missing: [...new Set(missing)],
|
|
2650
2801
|
problems,
|
|
2651
2802
|
envelope_sha256_ok: envelopeShaOk,
|
|
2803
|
+
signature_status: signatureStatus,
|
|
2652
2804
|
},
|
|
2653
2805
|
};
|
|
2654
2806
|
|
|
@@ -2685,6 +2837,9 @@ export class RabbitState {
|
|
|
2685
2837
|
});
|
|
2686
2838
|
|
|
2687
2839
|
// §7: results go into the ledger so the dedupe index survives a restart.
|
|
2840
|
+
// v1.2.7: a settled task may be the prerequisite that frees its dependents.
|
|
2841
|
+
this._releaseDependents(taskId);
|
|
2842
|
+
|
|
2688
2843
|
this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
|
|
2689
2844
|
if (machineId) {
|
|
2690
2845
|
const changed = task.leases.get(machineId);
|
|
@@ -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
|
+
}
|
package/src/util/dataflow.mjs
CHANGED
|
@@ -41,7 +41,8 @@ const SHA256 = /^[0-9a-f]{64}$/;
|
|
|
41
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
42
|
* complete.
|
|
43
43
|
*
|
|
44
|
-
* @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
|
|
44
|
+
* @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string, index?: number}>,
|
|
45
|
+
* expectedIndexTotal?: number|null,
|
|
45
46
|
* reducerMachineId?: string}} input
|
|
46
47
|
* @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
|
|
47
48
|
* {ok: false, code: string, detail: object}}
|
|
@@ -51,6 +52,15 @@ export function buildReducePlan(input = {}) {
|
|
|
51
52
|
if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
|
|
52
53
|
const results = Array.isArray(input.results) ? input.results : null;
|
|
53
54
|
if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
|
|
55
|
+
/**
|
|
56
|
+
* v1.2.7: what the task SAID would be produced, not just what arrived.
|
|
57
|
+
*
|
|
58
|
+
* Without an expected count the plan can only describe the reports it was handed, so a split run where one shard never
|
|
59
|
+
* reported produced a plan that looked complete. The count comes from the task (index_total); a missing index is refused
|
|
60
|
+
* by name before the reduce step can run on a partial fleet.
|
|
61
|
+
*/
|
|
62
|
+
const expectedTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0 ? input.expectedIndexTotal : null;
|
|
63
|
+
const seenIndexes = new Set();
|
|
54
64
|
|
|
55
65
|
const seen = new Set();
|
|
56
66
|
const inputs = [];
|
|
@@ -59,6 +69,14 @@ export function buildReducePlan(input = {}) {
|
|
|
59
69
|
if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
|
|
60
70
|
if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
|
|
61
71
|
seen.add(machineId);
|
|
72
|
+
if (expectedTotal !== null) {
|
|
73
|
+
const index = Number.isInteger(result?.index) ? result.index : null;
|
|
74
|
+
if (index === null) return { ok: false, code: 'MAP_INDEX_MISSING', detail: { machineId } };
|
|
75
|
+
if (index < 0 || index >= expectedTotal) {
|
|
76
|
+
return { ok: false, code: 'MAP_INDEX_OUT_OF_RANGE', detail: { machineId, index, expectedIndexTotal: expectedTotal } };
|
|
77
|
+
}
|
|
78
|
+
seenIndexes.add(index);
|
|
79
|
+
}
|
|
62
80
|
const mode = result?.mode === 'blob' ? 'blob' : 'inline';
|
|
63
81
|
if (mode === 'blob') {
|
|
64
82
|
const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
|
|
@@ -68,5 +86,10 @@ export function buildReducePlan(input = {}) {
|
|
|
68
86
|
inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
|
|
69
87
|
}
|
|
70
88
|
}
|
|
89
|
+
if (expectedTotal !== null) {
|
|
90
|
+
const missing = [];
|
|
91
|
+
for (let index = 0; index < expectedTotal; index += 1) if (!seenIndexes.has(index)) missing.push(index);
|
|
92
|
+
if (missing.length > 0) return { ok: false, code: 'MISSING_SHARD', detail: { missing, expectedIndexTotal: expectedTotal } };
|
|
93
|
+
}
|
|
71
94
|
return { ok: true, reducerMachineId: reducer, inputs };
|
|
72
95
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission matrix: which fleet role may perform which action.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a table, and why refusals are named
|
|
5
|
+
*
|
|
6
|
+
* A role check scattered across call sites drifts: one route checks, another forgets, and the matrix exists only in a document.
|
|
7
|
+
* This table is the single answer, and a refusal always names the action, the role required and the role held -- an
|
|
8
|
+
* "access denied" without those three facts is a support ticket, not a diagnostic.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the levels are ordinal
|
|
11
|
+
*
|
|
12
|
+
* Roles form an order (observer < worker < owner). Stating a minimum level rather than enumerating every allowed role means a
|
|
13
|
+
* new role slots in between two existing ones without rewriting every action.
|
|
14
|
+
*/
|
|
15
|
+
export const FLEET_ACTIONS = Object.freeze(['read_private', 'vote', 'join', 'dispatch', 'approve', 'manage_members']);
|
|
16
|
+
|
|
17
|
+
/** Lower number = fewer rights. */
|
|
18
|
+
export const ROLE_LEVEL = Object.freeze({ observer: 0, worker: 1, owner: 2 });
|
|
19
|
+
|
|
20
|
+
/** The minimum level an action needs. `manage_members` is owner-only; approving is owner-only for the same reason. */
|
|
21
|
+
export const ACTION_MIN_LEVEL = Object.freeze({
|
|
22
|
+
read_private: 0,
|
|
23
|
+
vote: 1,
|
|
24
|
+
join: 1,
|
|
25
|
+
dispatch: 1,
|
|
26
|
+
approve: 2,
|
|
27
|
+
manage_members: 2,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The role a level maps back to, for a refusal that says which role would have been enough. */
|
|
31
|
+
export function roleForLevel(level) {
|
|
32
|
+
const best = Object.entries(ROLE_LEVEL).filter(([, value]) => value === level)[0];
|
|
33
|
+
return best === undefined ? null : best[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {{role?: string|null, action: string}} input
|
|
38
|
+
* @returns {{ok: true, action: string, role: string}} | {ok: false, code: 'FORBIDDEN_ACTION', action: string, required_level: number, current_level: number, required_role: string|null, current_role: string}}
|
|
39
|
+
*/
|
|
40
|
+
export function can({ role = null, action } = {}) {
|
|
41
|
+
const currentRole = typeof role === 'string' && role in ROLE_LEVEL ? role : 'worker'; // unknown device = worker, never owner
|
|
42
|
+
if (!FLEET_ACTIONS.includes(action)) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false, code: 'FORBIDDEN_ACTION', action, required_level: null, current_level: ROLE_LEVEL[currentRole],
|
|
45
|
+
required_role: null, current_role: currentRole,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const requiredLevel = ACTION_MIN_LEVEL[action];
|
|
49
|
+
const currentLevel = ROLE_LEVEL[currentRole];
|
|
50
|
+
if (currentLevel >= requiredLevel) return { ok: true, action, role: currentRole };
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'FORBIDDEN_ACTION',
|
|
54
|
+
action,
|
|
55
|
+
required_level: requiredLevel,
|
|
56
|
+
current_level: currentLevel,
|
|
57
|
+
required_role: roleForLevel(requiredLevel),
|
|
58
|
+
current_role: currentRole,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every action with its verdict for one role -- the board an operator reads. */
|
|
63
|
+
export function matrixFor(role) {
|
|
64
|
+
return FLEET_ACTIONS.map((action) => ({ action, ...can({ role, action }) }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output redaction: secrets a command prints are replaced before they leave this machine.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a replacement, not a deletion
|
|
5
|
+
*
|
|
6
|
+
* Deleting the line would keep the secret out of the envelope and also destroy the evidence: line numbers and structure are
|
|
7
|
+
* what make a failure reproducible. So each match becomes [redacted:class] in place.
|
|
8
|
+
*
|
|
9
|
+
* ## Why nothing here touches a hash
|
|
10
|
+
*
|
|
11
|
+
* stdout_sha256 and stdout_bytes cover the RAW bytes and are computed by the executor. Redacting the text must not feed back
|
|
12
|
+
* into them: the envelope's hashes stay evidence, and only the human-readable heads/tails are filtered. The same is true of
|
|
13
|
+
* blobs -- a blob's name is the hash of its bytes, and rewriting them would make the name a lie.
|
|
14
|
+
*
|
|
15
|
+
* ## Why it can be turned off
|
|
16
|
+
*
|
|
17
|
+
* An operator debugging a credential problem needs the original. Turning it off is allowed and SAYS SO (unredacted: true in the
|
|
18
|
+
* envelope), because a redaction that silently does nothing is worse than no redaction at all.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Each rule: a class name and the pattern that finds it. Order matters only for the private-key block. */
|
|
22
|
+
const RULES = Object.freeze([
|
|
23
|
+
{ cls: 'private-key', pattern: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
24
|
+
{ cls: 'api-key', pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g },
|
|
25
|
+
{ cls: 'api-key', pattern: /\bnpm_[A-Za-z0-9]{20,}\b/g },
|
|
26
|
+
{ cls: 'api-key', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
|
|
27
|
+
{ cls: 'api-key', pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
28
|
+
{ cls: 'api-key', pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
29
|
+
{ cls: 'aws-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @returns {{text: string, replaced: Array<{cls: string, count: number}>}}
|
|
35
|
+
*/
|
|
36
|
+
export function redactText(text) {
|
|
37
|
+
if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', replaced: [] };
|
|
38
|
+
let out = text;
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const rule of RULES) {
|
|
41
|
+
let hits = 0;
|
|
42
|
+
out = out.replace(rule.pattern, () => { hits += 1; return '[redacted:' + rule.cls + ']'; });
|
|
43
|
+
if (hits > 0) counts.set(rule.cls, (counts.get(rule.cls) ?? 0) + hits);
|
|
44
|
+
}
|
|
45
|
+
return { text: out, replaced: [...counts.entries()].map(([cls, count]) => ({ cls, count })) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The envelope fields that carry command output and are therefore filtered. */
|
|
49
|
+
export const REDACTABLE_FIELDS = Object.freeze(['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail']);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} envelope
|
|
53
|
+
* @param {{enabled?: boolean}} [options]
|
|
54
|
+
* @returns {{envelope: object, redacted: Array<{field: string, cls: string, count: number}>}}
|
|
55
|
+
*/
|
|
56
|
+
export function redactEnvelope(envelope, { enabled = true } = {}) {
|
|
57
|
+
if (envelope === null || typeof envelope !== 'object') return { envelope, redacted: [] };
|
|
58
|
+
if (enabled !== true) return { envelope: { ...envelope, unredacted: true }, redacted: [] };
|
|
59
|
+
const next = { ...envelope };
|
|
60
|
+
const redacted = [];
|
|
61
|
+
for (const field of REDACTABLE_FIELDS) {
|
|
62
|
+
const value = next[field];
|
|
63
|
+
if (typeof value !== 'string' || value === '') continue;
|
|
64
|
+
const result = redactText(value);
|
|
65
|
+
next[field] = result.text;
|
|
66
|
+
for (const hit of result.replaced) redacted.push({ field, ...hit });
|
|
67
|
+
}
|
|
68
|
+
if (redacted.length > 0) next.redacted = redacted;
|
|
69
|
+
return { envelope: next, redacted };
|
|
70
|
+
}
|