@twinsearth/w2m-dsh-plugin 0.5.2 → 0.5.4
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/CHANGELOG.md +57 -0
- package/PROTOCOL.md +122 -0
- package/docs/P2P-CRYPTO.md +225 -0
- package/lib/client.js +513 -0
- package/package.json +7 -3
- package/src/agent/agent.mjs +199 -5
- package/src/agent/crypto.mjs +1618 -0
- package/src/agent/p2p-node.mjs +276 -5
- package/src/agent/p2p.mjs +667 -40
- package/src/plugin/fleet-target.mjs +603 -0
- package/src/plugin/tools.mjs +348 -39
- package/src/relay/peers.mjs +56 -0
package/src/plugin/tools.mjs
CHANGED
|
@@ -74,6 +74,10 @@ import {
|
|
|
74
74
|
// another's, so a second implementation of the formula that drifted by one input would make every
|
|
75
75
|
// reverse dial time out while both ends insisted they were sending.
|
|
76
76
|
import { mintPunchId, punchSession } from '../agent/p2p.mjs';
|
|
77
|
+
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
78
|
+
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
79
|
+
// implementations of a handshake are two chances to disagree about it.
|
|
80
|
+
import { DeviceKey } from '../agent/crypto.mjs';
|
|
77
81
|
import { parseServer } from '../agent/stun.mjs';
|
|
78
82
|
// The result-deduplication identity the relay mints. Imported rather than re-implemented: the value
|
|
79
83
|
// is compared byte for byte by the executor, so a second copy of the formula that drifted by one
|
|
@@ -81,6 +85,18 @@ import { parseServer } from '../agent/stun.mjs';
|
|
|
81
85
|
// the dedupe key exists to prevent.
|
|
82
86
|
import { SHELL_ID_DIRECT, computeCommandHash, computeDedupeKey } from '../relay/state.mjs';
|
|
83
87
|
import { createAutoUpdater, findProfileDir, resolveCurrentVersion } from './auto-update.mjs';
|
|
88
|
+
// v0.6.1: the session's chosen target machine, and the two browser routes the composer control
|
|
89
|
+
// reads it through. Imported rather than inlined because the control and `w2m_run` must agree on
|
|
90
|
+
// what "selected" means, and a second copy of that rule is a second chance to disagree.
|
|
91
|
+
import {
|
|
92
|
+
createLocalIdentity,
|
|
93
|
+
defaultSelectionStore,
|
|
94
|
+
dispatchSelectionFields,
|
|
95
|
+
readFleet,
|
|
96
|
+
registerTargetRoutes,
|
|
97
|
+
sessionKeyFor,
|
|
98
|
+
TARGET_ROUTE_PATH,
|
|
99
|
+
} from './fleet-target.mjs';
|
|
84
100
|
import { loadSharedConfig, describeSharedConfig } from './shared-config.mjs';
|
|
85
101
|
import { DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
|
|
86
102
|
|
|
@@ -115,7 +131,7 @@ const MAX_STAGES = 16;
|
|
|
115
131
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
116
132
|
* overrides it at runtime for tests and packagers.
|
|
117
133
|
*/
|
|
118
|
-
const PLUGIN_VERSION = '0.5.
|
|
134
|
+
const PLUGIN_VERSION = '0.5.4';
|
|
119
135
|
|
|
120
136
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
121
137
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -1077,9 +1093,27 @@ function createP2PTransport(cfg) {
|
|
|
1077
1093
|
* relay and a v0.4.x agent ignore keys they do not read. `relay` mode publishes `{mode}` alone: there
|
|
1078
1094
|
* is no punch, and saying so is better than publishing a stale one.
|
|
1079
1095
|
*
|
|
1096
|
+
* v0.6.0 adds one more key to the same block, and it is the whole of the key distribution problem:
|
|
1097
|
+
*
|
|
1098
|
+
* `static_keys: { [machine_id]: base64(x25519 public key) }`
|
|
1099
|
+
*
|
|
1100
|
+
* WHY A MAP RATHER THAN ONE KEY
|
|
1101
|
+
*
|
|
1102
|
+
* The executor needs the *dispatcher's* key before it can dial back, and the dispatcher needs the
|
|
1103
|
+
* *executor's* key before it can push — and at dispatch time the relay has not yet said which
|
|
1104
|
+
* machines the task went to (`leases` come back in the same response that creates it). So the block
|
|
1105
|
+
* carries this machine's own key, which is known before the request is written, and
|
|
1106
|
+
* {@link offerP2PFor} adds the target's once the lease names it. Reading the field is therefore
|
|
1107
|
+
* "look up the machine you are about to talk to", never "assume the only entry is the peer" — a
|
|
1108
|
+
* machine that receives an offer for a fleet of three has three entries and uses one.
|
|
1109
|
+
*
|
|
1110
|
+
* What that buys, in the document's own terms: no `[Peer]` block for anyone to fill in. A machine
|
|
1111
|
+
* learns the keys of exactly the peers it has been asked to talk to, from the offer that asked it.
|
|
1112
|
+
*
|
|
1080
1113
|
* @param {ReturnType<typeof readConfig>} cfg
|
|
1081
1114
|
* @param {object} runtime The P2P runtime handle from {@link createP2PRuntime}.
|
|
1082
|
-
* @returns {{mode: string, punch?: string, candidates?: Array<{address:string, port:number}
|
|
1115
|
+
* @returns {{mode: string, punch?: string, candidates?: Array<{address:string, port:number}>,
|
|
1116
|
+
* static_keys?: Record<string, string>}}
|
|
1083
1117
|
*/
|
|
1084
1118
|
function buildP2POfferBlock(cfg, runtime) {
|
|
1085
1119
|
const block = { mode: cfg.p2pMode };
|
|
@@ -1096,6 +1130,14 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1096
1130
|
if (Array.isArray(candidates) && candidates.length > 0) {
|
|
1097
1131
|
block.candidates = candidates.map((candidate) => ({ address: candidate.address, port: candidate.port }));
|
|
1098
1132
|
}
|
|
1133
|
+
// This machine's own static public key. Absent (not null) when there is none, so the wire stays
|
|
1134
|
+
// byte-identical to v0.5.x for a machine that has no key — an additive change has to be absent to
|
|
1135
|
+
// be additive.
|
|
1136
|
+
const own = runtime?.encryption?.public_key;
|
|
1137
|
+
const machineId = runtime?.machineId;
|
|
1138
|
+
if (typeof own === 'string' && own !== '' && typeof machineId === 'string' && machineId !== '') {
|
|
1139
|
+
block.static_keys = { [machineId]: own };
|
|
1140
|
+
}
|
|
1099
1141
|
return block;
|
|
1100
1142
|
}
|
|
1101
1143
|
|
|
@@ -1108,16 +1150,25 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1108
1150
|
* dispatcher is not filtering for would be answered by its `accept()` at best — a second session,
|
|
1109
1151
|
* a second channel, and two live sessions for one task where one would do.
|
|
1110
1152
|
*
|
|
1153
|
+
* v0.6.0 adds the *target's* key to `static_keys` when the caller supplies one, which is the half of
|
|
1154
|
+
* the distribution the published block could not carry: the dispatcher learns the executor's key from
|
|
1155
|
+
* the relay's peer record and hands it over in the frame addressed to that machine.
|
|
1156
|
+
*
|
|
1111
1157
|
* @param {unknown} published The block from {@link buildP2POfferBlock}, or whatever the caller sent.
|
|
1112
|
-
* @param {{taskId: string, machineId: string}} identity
|
|
1158
|
+
* @param {{taskId: string, machineId: string, peerKey?: string|null}} identity
|
|
1113
1159
|
* @returns {object|null}
|
|
1114
1160
|
*/
|
|
1115
|
-
function offerP2PFor(published, { taskId, machineId }) {
|
|
1161
|
+
function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
|
|
1116
1162
|
if (published === null || published === undefined) return null;
|
|
1117
1163
|
if (typeof published !== 'object' || Array.isArray(published)) return null;
|
|
1118
1164
|
const block = { ...published };
|
|
1119
1165
|
const session = punchSession({ punch: block.punch, taskId, machineId });
|
|
1120
1166
|
if (session !== null) block.session = session;
|
|
1167
|
+
if (typeof peerKey === 'string' && peerKey !== '') {
|
|
1168
|
+
const keys = block.static_keys !== null && typeof block.static_keys === 'object' ? { ...block.static_keys } : {};
|
|
1169
|
+
keys[machineId] = peerKey;
|
|
1170
|
+
block.static_keys = keys;
|
|
1171
|
+
}
|
|
1121
1172
|
return block;
|
|
1122
1173
|
}
|
|
1123
1174
|
|
|
@@ -1142,9 +1193,11 @@ function offerP2PFor(published, { taskId, machineId }) {
|
|
|
1142
1193
|
* @param {object} options.lease
|
|
1143
1194
|
* @param {object} options.payload The validated `POST /v1/task` body this plugin sent.
|
|
1144
1195
|
* @param {string|null} options.originMachineId
|
|
1196
|
+
* @param {string|null} [options.peerKey] The target's static public key (v0.6.0), added to the
|
|
1197
|
+
* frame's `p2p.static_keys` under that machine's id.
|
|
1145
1198
|
* @returns {{frame: object|null, skipped: string|null}}
|
|
1146
1199
|
*/
|
|
1147
|
-
function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
1200
|
+
function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = null }) {
|
|
1148
1201
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1149
1202
|
if (machineId === null) return { frame: null, skipped: 'the relay returned a lease with no machine_id' };
|
|
1150
1203
|
|
|
@@ -1183,7 +1236,7 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
|
1183
1236
|
requirements: payload.requirements,
|
|
1184
1237
|
compare_policy: payload.compare_policy,
|
|
1185
1238
|
origin_machine_id: originMachineId,
|
|
1186
|
-
p2p: offerP2PFor(payload.p2p, { taskId, machineId }),
|
|
1239
|
+
p2p: offerP2PFor(payload.p2p, { taskId, machineId, peerKey }),
|
|
1187
1240
|
},
|
|
1188
1241
|
skipped: null,
|
|
1189
1242
|
};
|
|
@@ -1201,19 +1254,55 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
|
1201
1254
|
*
|
|
1202
1255
|
* @param {P2PNode} node
|
|
1203
1256
|
* @param {object} frame
|
|
1204
|
-
* @
|
|
1257
|
+
* @param {{peerKey?: string|null, peerKeys?: object|null}} [crypto]
|
|
1258
|
+
* `peerKey` is the target's static public key, published in this very frame
|
|
1259
|
+
* (`frame.p2p.static_keys[machine_id]`) so the executor can dial back; it is handed to `dial()` as
|
|
1260
|
+
* well because the *dispatcher* is the initiator here and needs the executor's key to handshake.
|
|
1261
|
+
* `peerKeys` is the node's own store, taught the same key so the accept side can verify the
|
|
1262
|
+
* executor's handshake later without a second relay lookup.
|
|
1263
|
+
* @returns {Promise<{ok: boolean, error: string|null, session: number|null, reused: boolean,
|
|
1264
|
+
* encrypted: boolean, encryption_reason: string|null}>}
|
|
1205
1265
|
*/
|
|
1206
|
-
async function pushOffer(node, frame) {
|
|
1266
|
+
async function pushOffer(node, frame, crypto = {}) {
|
|
1207
1267
|
const session = Number.isInteger(frame?.p2p?.session) && frame.p2p.session > 0 ? frame.p2p.session : undefined;
|
|
1268
|
+
const peerKey = typeof crypto.peerKey === 'string' && crypto.peerKey !== '' ? crypto.peerKey : null;
|
|
1269
|
+
if (peerKey !== null && crypto.peerKeys !== null && crypto.peerKeys !== undefined) {
|
|
1270
|
+
// Recorded before the dial: the executor's handshake answer arrives on the same socket, and the
|
|
1271
|
+
// `accept()` loop that has to verify it runs on this node. A key learned only from the relay's
|
|
1272
|
+
// answer would be a second lookup racing that datagram.
|
|
1273
|
+
crypto.peerKeys.set(frame.machine_id, peerKey, 'relay');
|
|
1274
|
+
}
|
|
1275
|
+
// The dial's own options, built here rather than inline because the *presence* of `crypto` is the
|
|
1276
|
+
// decision: `P2PNode.dial` arms a handshake only when this dispatcher holds a key for that peer, and
|
|
1277
|
+
// a peer with no published key is dialled exactly as v0.5.x dialled it — plaintext, recorded, and
|
|
1278
|
+
// never a failure. That is §4's fallback clause applied to the push: a fleet must never lose a
|
|
1279
|
+
// machine to a security upgrade. It is not a silent downgrade, because the peer never offered a key
|
|
1280
|
+
// to downgrade *from*; a peer whose key we hold and whose handshake fails produces `ok: false` and
|
|
1281
|
+
// no channel at all.
|
|
1282
|
+
const dialOptions = session === undefined ? {} : { session };
|
|
1208
1283
|
try {
|
|
1209
|
-
const dialled = await node.dial(frame.machine_id,
|
|
1284
|
+
const dialled = await node.dial(frame.machine_id, dialOptions);
|
|
1210
1285
|
if (!dialled.ok) {
|
|
1211
|
-
return {
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
error: dialled.error ?? 'P2P_DIAL_FAILED',
|
|
1289
|
+
session: dialled.session ?? null,
|
|
1290
|
+
reused: false,
|
|
1291
|
+
encrypted: false,
|
|
1292
|
+
encryption_reason: dialled.encryption_reason ?? null,
|
|
1293
|
+
};
|
|
1212
1294
|
}
|
|
1213
1295
|
const channel = dialled.channel;
|
|
1214
1296
|
try {
|
|
1215
1297
|
await channel.send(JSON.stringify(frame));
|
|
1216
|
-
return {
|
|
1298
|
+
return {
|
|
1299
|
+
ok: true,
|
|
1300
|
+
error: null,
|
|
1301
|
+
session: dialled.session ?? null,
|
|
1302
|
+
reused: dialled.reused === true,
|
|
1303
|
+
encrypted: channel?.encryption?.encrypted === true,
|
|
1304
|
+
encryption_reason: channel?.encryption?.reason ?? null,
|
|
1305
|
+
};
|
|
1217
1306
|
} finally {
|
|
1218
1307
|
// The offer is one frame over a reliable channel: holding the channel open would keep a NAT
|
|
1219
1308
|
// mapping alive for a conversation that is over, and the result comes back the responder's way.
|
|
@@ -1225,6 +1314,8 @@ async function pushOffer(node, frame) {
|
|
|
1225
1314
|
error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}`,
|
|
1226
1315
|
session: session ?? null,
|
|
1227
1316
|
reused: false,
|
|
1317
|
+
encrypted: false,
|
|
1318
|
+
encryption_reason: null,
|
|
1228
1319
|
};
|
|
1229
1320
|
}
|
|
1230
1321
|
}
|
|
@@ -1238,11 +1329,19 @@ async function pushOffer(node, frame) {
|
|
|
1238
1329
|
* were never handed the command, and sending them an offer would be asking them to run work the
|
|
1239
1330
|
* relay decided they must not run.
|
|
1240
1331
|
*
|
|
1332
|
+
* v0.6.0: each target's static key is fetched from the relay's peer record before the push, which is
|
|
1333
|
+
* the one moment the dispatcher learns the key of a machine it did not already know. It is fetched
|
|
1334
|
+
* **here** rather than sourced from the stored `p2p` block because the block is written *before* the
|
|
1335
|
+
* relay decides which machines the task goes to — the offer that carries the key is the one addressed
|
|
1336
|
+
* to the machine that key belongs to. A missing key is not an error: the push proceeds and the
|
|
1337
|
+
* executor records `P2P_CRYPTO_NO_KEY` for its plaintext channel, which is the documented outcome for
|
|
1338
|
+
* a peer that never announced.
|
|
1339
|
+
*
|
|
1241
1340
|
* @param {P2PNode} node
|
|
1242
1341
|
* @param {object} options
|
|
1243
1342
|
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order.
|
|
1244
1343
|
*/
|
|
1245
|
-
async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
1344
|
+
async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
|
|
1246
1345
|
const out = [];
|
|
1247
1346
|
for (const lease of leases) {
|
|
1248
1347
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
@@ -1259,12 +1358,13 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
|
1259
1358
|
});
|
|
1260
1359
|
continue;
|
|
1261
1360
|
}
|
|
1262
|
-
const
|
|
1361
|
+
const peerKey = await readPeerStaticKey(getJson, machineId);
|
|
1362
|
+
const built = buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey });
|
|
1263
1363
|
if (built.frame === null) {
|
|
1264
1364
|
out.push({ machine_id: machineId, ok: false, error: built.skipped });
|
|
1265
1365
|
continue;
|
|
1266
1366
|
}
|
|
1267
|
-
const result = await pushOffer(node, built.frame);
|
|
1367
|
+
const result = await pushOffer(node, built.frame, { peerKey, peerKeys: node?.peerKeys ?? null });
|
|
1268
1368
|
out.push({
|
|
1269
1369
|
machine_id: machineId,
|
|
1270
1370
|
ok: result.ok,
|
|
@@ -1277,11 +1377,42 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
|
1277
1377
|
// True when the executor's dial got there first and this push travelled over the channel that
|
|
1278
1378
|
// dial opened — a two-way punch that worked, reported as such rather than as a plain push.
|
|
1279
1379
|
reused: result.reused === true,
|
|
1380
|
+
// v0.6.0: whether the offer actually travelled sealed, and why not when it did not. Reported
|
|
1381
|
+
// per machine because it is a per-machine fact: one peer can be encrypted and the next one a
|
|
1382
|
+
// v0.5.x build in the same dispatch.
|
|
1383
|
+
encrypted: result.encrypted === true,
|
|
1384
|
+
...(result.encryption_reason === null || result.encryption_reason === undefined
|
|
1385
|
+
? {}
|
|
1386
|
+
: { encryption_reason: result.encryption_reason }),
|
|
1387
|
+
...(peerKey === null ? {} : { static_key: 'published' }),
|
|
1280
1388
|
});
|
|
1281
1389
|
}
|
|
1282
1390
|
return out;
|
|
1283
1391
|
}
|
|
1284
1392
|
|
|
1393
|
+
/**
|
|
1394
|
+
* Read one machine's static public key from the relay's peer record, or explain why not.
|
|
1395
|
+
*
|
|
1396
|
+
* Never throws and never fails a dispatch: a relay that answers 404 (the peer never announced, or its
|
|
1397
|
+
* announcement expired between the task and the push) leaves this machine unencrypted for that peer
|
|
1398
|
+
* and says so, which is precisely §4's "a fleet must never lose a machine because a security upgrade
|
|
1399
|
+
* could not complete".
|
|
1400
|
+
*
|
|
1401
|
+
* @param {((path: string) => Promise<object>)|null} getJson
|
|
1402
|
+
* @param {string} machineId
|
|
1403
|
+
* @returns {Promise<string|null>}
|
|
1404
|
+
*/
|
|
1405
|
+
async function readPeerStaticKey(getJson, machineId) {
|
|
1406
|
+
if (typeof getJson !== 'function') return null;
|
|
1407
|
+
try {
|
|
1408
|
+
const answer = await getJson(`/v1/peer/${encodeURIComponent(machineId)}`);
|
|
1409
|
+
const key = answer?.json?.peer?.static_key;
|
|
1410
|
+
return typeof key === 'string' && key !== '' ? key : null;
|
|
1411
|
+
} catch {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1285
1416
|
/**
|
|
1286
1417
|
* Build the object the tools reach the direct path through, and register its effect disposer.
|
|
1287
1418
|
*
|
|
@@ -1381,6 +1512,40 @@ function createP2PRuntime(ctx, cfg, block = {}) {
|
|
|
1381
1512
|
return runtime;
|
|
1382
1513
|
}
|
|
1383
1514
|
|
|
1515
|
+
/**
|
|
1516
|
+
* Read the dispatcher's own static keypair, or explain why there is none (v0.6.0).
|
|
1517
|
+
*
|
|
1518
|
+
* The key lives beside `device.json`, which is the one directory this plugin already knows how to
|
|
1519
|
+
* find (§ the note on `readDevice`): a `stateDir` when the operator set one, otherwise the
|
|
1520
|
+
* `DSH_HOME/xclient` default. Generating it here is the same "generated the first time a machine
|
|
1521
|
+
* pairs" rule the agent follows, and the two processes read the *same file*, so a machine that both
|
|
1522
|
+
* dispatches and executes has one identity on the direct path rather than two.
|
|
1523
|
+
*
|
|
1524
|
+
* Never throws. A caller cannot fix an unreadable key file from a tool call, and a dispatch that
|
|
1525
|
+
* refused because a *security upgrade* could not complete would be exactly the fleet-losing failure
|
|
1526
|
+
* §4 forbids — so the failure becomes a reason string, the node runs without a key, and every offer
|
|
1527
|
+
* this machine publishes is plainly labelled as having none.
|
|
1528
|
+
*
|
|
1529
|
+
* @param {object|null} injected Test seam: a `DeviceKey`, or null to use the file.
|
|
1530
|
+
* @param {string|null} deviceFile Absolute path of `device.json`, or null when unknown.
|
|
1531
|
+
* @returns {{key: object|null, error: string|null}}
|
|
1532
|
+
*/
|
|
1533
|
+
function loadDeviceKey(injected, deviceFile) {
|
|
1534
|
+
if (injected !== null && injected !== undefined) return { key: injected, error: null };
|
|
1535
|
+
if (typeof deviceFile !== 'string' || deviceFile === '') {
|
|
1536
|
+
return { key: null, error: 'P2P_CRYPTO_NO_KEY: device.json was not located, so device.key has no directory' };
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
const loaded = DeviceKey.load({ dir: path.dirname(deviceFile) });
|
|
1540
|
+
return { key: loaded.key, error: null };
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
return {
|
|
1543
|
+
key: null,
|
|
1544
|
+
error: `P2P_CRYPTO_KEY_UNREADABLE: ${error instanceof Error ? error.message : String(error)}`,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1384
1549
|
/**
|
|
1385
1550
|
* Read the identity, build the node, and start it.
|
|
1386
1551
|
*
|
|
@@ -1406,6 +1571,19 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1406
1571
|
return runtime.start;
|
|
1407
1572
|
}
|
|
1408
1573
|
|
|
1574
|
+
// v0.6.0: the dispatcher's own static keypair, beside device.json. A failure here is *not* fatal
|
|
1575
|
+
// and must not stop the node: a machine with an unreadable key file is still a working W2M machine
|
|
1576
|
+
// whose tasks travel over the relay, which is the same rule every other crypto failure follows.
|
|
1577
|
+
// What it must not do is pretend: the key stays null, `runtime.encryption.reason` names the
|
|
1578
|
+
// failure, and every offer this machine publishes says no keys.
|
|
1579
|
+
const keyLoad = loadDeviceKey(block.deviceKey ?? null, identity.path);
|
|
1580
|
+
runtime.deviceKey = keyLoad.key;
|
|
1581
|
+
runtime.encryption = {
|
|
1582
|
+
enabled: keyLoad.key !== null,
|
|
1583
|
+
public_key: keyLoad.key === null ? null : keyLoad.key.publicKeyBase64,
|
|
1584
|
+
error: keyLoad.error,
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1409
1587
|
const transport = createP2PTransport(cfg);
|
|
1410
1588
|
// `ctx.effect` owns the node when the host provides it, and the fallback is the same ownership
|
|
1411
1589
|
// with a shorter lifetime: a host that provides no effect (an older Cordis, or a bare test
|
|
@@ -1418,6 +1596,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1418
1596
|
getJson: transport.getJson,
|
|
1419
1597
|
stunServers: cfg.stunServers,
|
|
1420
1598
|
log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
|
|
1599
|
+
...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
|
|
1421
1600
|
...block,
|
|
1422
1601
|
});
|
|
1423
1602
|
|
|
@@ -1604,6 +1783,22 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
|
|
|
1604
1783
|
punches_in: status?.punches_in ?? 0,
|
|
1605
1784
|
dial_failures: status?.dial_failures ?? 0,
|
|
1606
1785
|
transport: p2pTransportFacts(runtime.node),
|
|
1786
|
+
// v0.6.0: the crypto facts, named one by one rather than mirrored wholesale. `enabled` is about
|
|
1787
|
+
// *this* machine's key; `encrypted_channels` and `plaintext_channels` are about the channels it
|
|
1788
|
+
// has actually made, and they can both be non-zero on a mixed fleet. `error` is the reason there
|
|
1789
|
+
// is no key when there is none — a reader has to be able to tell "this build has no crypto" from
|
|
1790
|
+
// "the key file is unreadable" without reading a log.
|
|
1791
|
+
encryption: {
|
|
1792
|
+
enabled: runtime.encryption?.enabled === true,
|
|
1793
|
+
public_key: runtime.encryption?.public_key ?? null,
|
|
1794
|
+
error: runtime.encryption?.error ?? null,
|
|
1795
|
+
peers_known: status?.encryption?.peers_known ?? 0,
|
|
1796
|
+
handshakes: status?.encryption?.handshakes ?? 0,
|
|
1797
|
+
encrypted_channels: status?.encryption?.encrypted_channels ?? 0,
|
|
1798
|
+
plaintext_channels: status?.encryption?.plaintext_channels ?? 0,
|
|
1799
|
+
refused: status?.encryption?.refused ?? 0,
|
|
1800
|
+
refusals: Array.isArray(status?.encryption?.refusals) ? status.encryption.refusals : [],
|
|
1801
|
+
},
|
|
1607
1802
|
inbox: {
|
|
1608
1803
|
dir: runtime.inboxDir,
|
|
1609
1804
|
depth: inboxDepth,
|
|
@@ -2662,6 +2857,32 @@ export async function apply(ctx, config = {}) {
|
|
|
2662
2857
|
// still has to dispatch, and `w2m_status` is where the failure is reported.
|
|
2663
2858
|
const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
|
|
2664
2859
|
|
|
2860
|
+
/**
|
|
2861
|
+
* This machine's fleet identity, read once per successful resolution.
|
|
2862
|
+
*
|
|
2863
|
+
* The memoization rule lives in {@link createLocalIdentity} so the route half and the tool half
|
|
2864
|
+
* cannot disagree about when a missing identity becomes a stale one.
|
|
2865
|
+
*/
|
|
2866
|
+
const localIdentity = createLocalIdentity(async () => {
|
|
2867
|
+
const device = await readDevice({ stateDir: cfg.stateDir });
|
|
2868
|
+
return { machine_id: device.machine_id, machine_name: device.machine_name, machineName: cfg.machineName };
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
// v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
|
|
2872
|
+
// host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence —
|
|
2873
|
+
// which is the honest place for it, since a host that has no Connection at all is simply not a web
|
|
2874
|
+
// composition and does not need a warning on every headless run.
|
|
2875
|
+
const targetRoutes = registerTargetRoutes(ctx, {
|
|
2876
|
+
cfg: () => cfg,
|
|
2877
|
+
store: defaultSelectionStore,
|
|
2878
|
+
localIdentity,
|
|
2879
|
+
request,
|
|
2880
|
+
resolveToken,
|
|
2881
|
+
});
|
|
2882
|
+
if (targetRoutes.unexpected) {
|
|
2883
|
+
ctx.logger?.warn?.(`w2m: the composer target control is inactive — ${targetRoutes.reason}`);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2665
2886
|
/** Text output: rendered as JSON, because every one of these tools answers with a structure. */
|
|
2666
2887
|
const jsonOutput = {
|
|
2667
2888
|
schema: { type: 'string' },
|
|
@@ -2689,48 +2910,61 @@ export async function apply(ctx, config = {}) {
|
|
|
2689
2910
|
throwIfAborted(exec?.signal);
|
|
2690
2911
|
const base = requireRabbit(cfg);
|
|
2691
2912
|
const token = await resolveToken(cfg);
|
|
2692
|
-
|
|
2913
|
+
|
|
2914
|
+
// The read itself, the liveness rule, and "this machine has no identity yet" all come from
|
|
2915
|
+
// the same helper the composer control uses, so the list a model reads and the list an
|
|
2916
|
+
// operator clicks can never disagree about who is online.
|
|
2917
|
+
const identity = await localIdentity();
|
|
2918
|
+
const fleet = await readFleet({
|
|
2693
2919
|
rabbitUrl: base,
|
|
2694
2920
|
token,
|
|
2921
|
+
localMachineId: identity.machine_id,
|
|
2695
2922
|
signal: exec?.signal,
|
|
2696
|
-
|
|
2923
|
+
request,
|
|
2697
2924
|
});
|
|
2698
|
-
if (!
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2925
|
+
if (!fleet.ok) {
|
|
2926
|
+
// The typed error is rebuilt here, from the helper's report, so the code, message and hint a
|
|
2927
|
+
// caller sees are the same three it saw before this read moved into `fleet-target.mjs`: the
|
|
2928
|
+
// remediation travels in `hint`, never in the message.
|
|
2929
|
+
const unauthorized = fleet.status === 401;
|
|
2930
|
+
throw new W2MError(
|
|
2931
|
+
unauthorized ? 'W2M_RABBIT_REFUSED' : 'W2M_UNREACHABLE',
|
|
2932
|
+
fleet.status === 0
|
|
2933
|
+
? `${fleet.error}`
|
|
2934
|
+
: `${fleet.error}; ${unauthorized ? 'this machine is not paired, or its device_token is stale - pair the agent again' : 'check the Rabbit log for the reason it gave'}`,
|
|
2935
|
+
{
|
|
2936
|
+
hint: fleet.hint ?? (unauthorized
|
|
2937
|
+
? 'this machine is not paired, or its device_token is stale; pair the agent again'
|
|
2938
|
+
: 'check the Rabbit log for the reason it gave'),
|
|
2939
|
+
},
|
|
2940
|
+
);
|
|
2704
2941
|
}
|
|
2705
2942
|
|
|
2706
2943
|
// `GET /v1/devices` answers `{protocol_version, rabbit_time, devices:[...]}`. Each entry is a
|
|
2707
2944
|
// device recor - the Rabbit's own lease bookkeeping is on the task, not her - so a device
|
|
2708
|
-
// is described by identity and capabilities, and
|
|
2709
|
-
//
|
|
2710
|
-
const machines = Array.isArray(result.json?.devices)
|
|
2711
|
-
? result.json.devices
|
|
2712
|
-
: (Array.isArray(result.json?.machines) ? result.json.machines : []);
|
|
2945
|
+
// is described by identity and capabilities, and the relay's own `state` is the honest
|
|
2946
|
+
// default for a device it still lists.
|
|
2713
2947
|
const includeStale = args?.include_stale === true;
|
|
2714
|
-
const listed =
|
|
2948
|
+
const listed = fleet.records.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
|
|
2715
2949
|
|
|
2716
2950
|
return JSON.stringify(
|
|
2717
2951
|
{
|
|
2718
2952
|
ok: true,
|
|
2719
2953
|
protocol_version: PROTOCOL_VERSION,
|
|
2720
|
-
rabbit_time:
|
|
2954
|
+
rabbit_time: fleet.rabbit_time,
|
|
2721
2955
|
count: listed.length,
|
|
2722
2956
|
devices: listed.map((machine) => ({
|
|
2723
2957
|
machine_id: machine?.machine_id ?? null,
|
|
2724
2958
|
name: machine?.machine_name ?? machine?.name ?? null,
|
|
2725
|
-
os: machine?.platform?.os ?? null,
|
|
2726
|
-
os_version: machine?.platform?.os_version ?? null,
|
|
2727
|
-
arch: machine?.platform?.arch ?? null,
|
|
2728
|
-
shell: machine?.platform?.shell ?? null,
|
|
2959
|
+
os: machine?.platform?.os ?? machine?.os ?? null,
|
|
2960
|
+
os_version: machine?.platform?.os_version ?? machine?.os_version ?? null,
|
|
2961
|
+
arch: machine?.platform?.arch ?? machine?.arch ?? null,
|
|
2962
|
+
shell: machine?.platform?.shell ?? machine?.shell ?? null,
|
|
2729
2963
|
caps: machine?.caps ?? null,
|
|
2730
2964
|
last_seen: machine?.last_seen ?? machine?.last_seen_at ?? null,
|
|
2731
2965
|
state: machine?.state ?? 'online',
|
|
2732
2966
|
})),
|
|
2733
|
-
excluded_stale: includeStale ? 0 :
|
|
2967
|
+
excluded_stale: includeStale ? 0 : fleet.records.length - listed.length,
|
|
2734
2968
|
note:
|
|
2735
2969
|
'a listed device has paired and is being tracked; it can still refuse the next command ' +
|
|
2736
2970
|
'at the capability gate, which w2m_wait reports per machine as `refused`',
|
|
@@ -2754,7 +2988,10 @@ export async function apply(ctx, config = {}) {
|
|
|
2754
2988
|
'Anchors (base_commit, base_tree) are computed here from this machine\'s working tree so ' +
|
|
2755
2989
|
'the peers can be checked against them. Under the default p2pMode the offer is also pushed ' +
|
|
2756
2990
|
'over the direct path, best effort: `direct_pushes` names every attempt, and the relay\'s ' +
|
|
2757
|
-
'own offer remains the delivery when a push fails.'
|
|
2991
|
+
'own offer remains the delivery when a push fails. ' +
|
|
2992
|
+
'A machine selected in the composer for this session narrows the dispatch to that machine; ' +
|
|
2993
|
+
'an explicit `executor_machine_id` always wins over it, and the answer\'s `selection` says ' +
|
|
2994
|
+
'which of the two routed the task.',
|
|
2758
2995
|
parameters: {
|
|
2759
2996
|
command_argv: {
|
|
2760
2997
|
type: 'array',
|
|
@@ -2977,6 +3214,34 @@ export async function apply(ctx, config = {}) {
|
|
|
2977
3214
|
);
|
|
2978
3215
|
}
|
|
2979
3216
|
|
|
3217
|
+
// -------------------------------------------------------------------------------------------
|
|
3218
|
+
// v0.6.1: the session's chosen target, when the caller named no machine of its own.
|
|
3219
|
+
// -------------------------------------------------------------------------------------------
|
|
3220
|
+
//
|
|
3221
|
+
// The composer control writes a selection into this process, keyed by session; here is where
|
|
3222
|
+
// it becomes real. Read before the payload literal so the routing facts and the answer agree.
|
|
3223
|
+
//
|
|
3224
|
+
// The precedence rule is the whole contract, and it is one line: **the caller's own fields
|
|
3225
|
+
// always win**. `executor_machine_id` is checked below, and `target_machines` is never a tool
|
|
3226
|
+
// argument — so a caller that named a machine (which, for this tool, means
|
|
3227
|
+
// `executor_machine_id` under broadcast) is untouched, and a caller that named none inherits
|
|
3228
|
+
// the selection. Nothing already published changes meaning: the selection only fills fields
|
|
3229
|
+
// that were absent, and the relay has accepted both of them since v0.3.3/v0.4.1.
|
|
3230
|
+
const sessionKey = sessionKeyFor(exec);
|
|
3231
|
+
const selection = defaultSelectionStore.get(sessionKey);
|
|
3232
|
+
const routedBy = dispatchSelectionFields(selection, mode);
|
|
3233
|
+
// broadcast reads the executor field; everything else reads `target_machines`. The relay
|
|
3234
|
+
// ignores the field a mode does not read, and the two are never both set by a selection.
|
|
3235
|
+
const selectionFields = mode === 'broadcast' && executorMachineId !== null
|
|
3236
|
+
? { fields: {}, applied_as: null }
|
|
3237
|
+
: routedBy.fields;
|
|
3238
|
+
// Whether the selection actually routed THIS task. Reported in the answer, because it is the
|
|
3239
|
+
// one thing an operator cannot recover afterwards: the payload is not in the transcript, and
|
|
3240
|
+
// the same command run twice can legitimately take two different routes.
|
|
3241
|
+
const selectionAppliedAs = mode === 'broadcast' && executorMachineId !== null
|
|
3242
|
+
? null
|
|
3243
|
+
: routedBy.applied_as;
|
|
3244
|
+
|
|
2980
3245
|
// Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
|
|
2981
3246
|
//
|
|
2982
3247
|
// **Every** stage is checked, not just the first. Checking only `command_argv` would let a
|
|
@@ -3073,6 +3338,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3073
3338
|
// Only sent for broadcast; the relay ignores it otherwise, and the validation above already
|
|
3074
3339
|
// refused the combination, so this cannot silently mean nothing.
|
|
3075
3340
|
...(executorMachineId !== null ? { executor_machine_id: executorMachineId } : {}),
|
|
3341
|
+
// v0.6.1: the elected machine, when the caller did not name one and a mode that reads it is
|
|
3342
|
+
// in play. Spread after the caller's own field above so the caller always wins even if a
|
|
3343
|
+
// future edit makes both possible.
|
|
3344
|
+
...selectionFields,
|
|
3076
3345
|
// Only for pipeline. The relay ignores `stages` under the other modes and the validation
|
|
3077
3346
|
// above refuses the combination, so this can never silently mean nothing.
|
|
3078
3347
|
...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
|
|
@@ -3096,15 +3365,22 @@ export async function apply(ctx, config = {}) {
|
|
|
3096
3365
|
// OPERATOR_REQUIRED gets its own guidance: it is the one refusal whose fix is a client-side
|
|
3097
3366
|
// setting rather than something on the relay.
|
|
3098
3367
|
const operatorRequired = result.error?.code === 'OPERATOR_REQUIRED' || result.status === 401;
|
|
3368
|
+
// v0.6.1: when a task was elected to one machine, NO_ONLINE_DEVICE is the *machine* being
|
|
3369
|
+
// unreachable, not the fleet being empty -- and the two have different fixes. Saying so is
|
|
3370
|
+
// the difference between "start an agent" and "the machine you picked went away".
|
|
3371
|
+
const selectedName = selection?.machine_name ?? selection?.machine_id ?? null;
|
|
3372
|
+
const selectedMissing = result.error?.code === 'NO_ONLINE_DEVICE' && selectionAppliedAs !== null;
|
|
3099
3373
|
throw new W2MError(
|
|
3100
3374
|
'W2M_RABBIT_REFUSED',
|
|
3101
|
-
`W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
|
|
3375
|
+
`W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : selectedMissing ? `this session's selected machine (${selectedName}) is not among the machines the relay currently holds, so a task narrowed to it has no target - pick another machine in the composer, or clear the selection` : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
|
|
3102
3376
|
{
|
|
3103
3377
|
hint: operatorRequired
|
|
3104
3378
|
? 'set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup, or read it from `<state>/operator-token.txt`'
|
|
3105
|
-
:
|
|
3106
|
-
?
|
|
3107
|
-
:
|
|
3379
|
+
: selectedMissing
|
|
3380
|
+
? `the selected target was sent as ${selectionAppliedAs}=${selection?.machine_id ?? ''}; clear the composer selection to dispatch to the whole fleet again`
|
|
3381
|
+
: result.error?.code === 'NO_ONLINE_DEVICE'
|
|
3382
|
+
? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
|
|
3383
|
+
: 'check the Rabbit log for the reason it gave',
|
|
3108
3384
|
},
|
|
3109
3385
|
);
|
|
3110
3386
|
}
|
|
@@ -3140,6 +3416,9 @@ export async function apply(ctx, config = {}) {
|
|
|
3140
3416
|
leases: Array.isArray(result.json?.leases) ? result.json.leases : [],
|
|
3141
3417
|
payload,
|
|
3142
3418
|
originMachineId,
|
|
3419
|
+
// v0.6.0: where each target's static public key is read from, so the offer addressed to a
|
|
3420
|
+
// machine can carry the key that machine needs to authenticate the dispatcher's handshake.
|
|
3421
|
+
getJson: p2p.node?.getJson ?? null,
|
|
3143
3422
|
});
|
|
3144
3423
|
directPushes = {
|
|
3145
3424
|
...directPushes,
|
|
@@ -3184,6 +3463,19 @@ export async function apply(ctx, config = {}) {
|
|
|
3184
3463
|
// v0.4.0: what the direct path tried, per machine. `mode` is repeated here rather than left
|
|
3185
3464
|
// to `w2m_status` so one dispatch's answer is self-contained.
|
|
3186
3465
|
direct_pushes: directPushes,
|
|
3466
|
+
// v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
|
|
3467
|
+
// which one. Published because a dispatch that ran somewhere other than where the operator
|
|
3468
|
+
// pointed is the one failure this feature could hide, and a caller that cannot see the
|
|
3469
|
+
// routing cannot report it.
|
|
3470
|
+
selection: selection === null
|
|
3471
|
+
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
|
|
3472
|
+
: {
|
|
3473
|
+
applied: selectionAppliedAs !== null,
|
|
3474
|
+
field: selectionAppliedAs,
|
|
3475
|
+
machine_id: selection.machine_id,
|
|
3476
|
+
machine_name: selection.machine_name,
|
|
3477
|
+
session: sessionKey,
|
|
3478
|
+
},
|
|
3187
3479
|
note: anchors.base_tree === null
|
|
3188
3480
|
? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
|
|
3189
3481
|
: 'peers are checked against base_commit + base_tree + command_hash',
|
|
@@ -3592,6 +3884,23 @@ export async function apply(ctx, config = {}) {
|
|
|
3592
3884
|
fingerprint_algo: 'git-temp-index-tree/v1',
|
|
3593
3885
|
error: anchors.error,
|
|
3594
3886
|
},
|
|
3887
|
+
/**
|
|
3888
|
+
* v0.6.1: the composer's target control and the selections it holds.
|
|
3889
|
+
*
|
|
3890
|
+
* Reported here because "the control is not in my composer" has exactly two causes — the
|
|
3891
|
+
* host has no fetch-route registry, or the browser half is not loaded — and this is the
|
|
3892
|
+
* one call that can tell them apart from the host side. The selections themselves are
|
|
3893
|
+
* counts, not contents: which session picked which machine is the operator's business and
|
|
3894
|
+
* nothing the model needs.
|
|
3895
|
+
*/
|
|
3896
|
+
target_selection: {
|
|
3897
|
+
route: TARGET_ROUTE_PATH,
|
|
3898
|
+
attached: targetRoutes.mounted,
|
|
3899
|
+
detail: targetRoutes.mounted
|
|
3900
|
+
? 'the composer control reads and writes its selection through this route'
|
|
3901
|
+
: targetRoutes.reason,
|
|
3902
|
+
sessions_with_a_target: defaultSelectionStore.list().length,
|
|
3903
|
+
},
|
|
3595
3904
|
notes: statusNotes(relay, rtt, cfg, p2pStatus),
|
|
3596
3905
|
},
|
|
3597
3906
|
null,
|