@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.
@@ -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.2';
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
- * @returns {Promise<{ok: boolean, error: string|null, session: number|null, reused: boolean}>}
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, session === undefined ? {} : { session });
1284
+ const dialled = await node.dial(frame.machine_id, dialOptions);
1210
1285
  if (!dialled.ok) {
1211
- return { ok: false, error: dialled.error ?? 'P2P_DIAL_FAILED', session: dialled.session ?? null, reused: false };
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 { ok: true, error: null, session: dialled.session ?? null, reused: dialled.reused === true };
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 built = buildOfferFrame({ taskId, lease, payload, originMachineId });
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
- const result = await request({
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
- pathname: '/v1/devices',
2923
+ request,
2697
2924
  });
2698
- if (!result.ok) {
2699
- throw new W2MError('W2M_RABBIT_REFUSED', `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/devices with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${result.status === 401 ? 'this machine is not paired, or its device_token is stal - pair the agent again' : 'check the Rabbit log for the reason it gave'}`, {
2700
- hint: result.status === 401
2701
- ? 'this machine is not paired, or its device_token is stale; pair the agent again'
2702
- : 'check the Rabbit log for the reason it gave',
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 "online" is the honest default for a device
2709
- // the Rabbit still lists.
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 = machines.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
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: result.json?.rabbit_time ?? null,
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 : machines.length - listed.length,
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
- : result.error?.code === 'NO_ONLINE_DEVICE'
3106
- ? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
3107
- : 'check the Rabbit log for the reason it gave',
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,