@twinsearth/w2m-dsh-plugin 2.0.8 → 2.0.9

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * W0: P2PTransport contract + the udp-v1 implementation.
3
+ *
4
+ * This is the seam a second stack (iroh-v1) will plug into. It is deliberately
5
+ * thin: it owns no protocol logic, only the shape the scheduler talks to.
6
+ * Nothing wires it into the agent yet, so behaviour of v2.0.8 is unchanged.
7
+ */
8
+
9
+ export const TRANSPORT = Object.freeze({
10
+ UDP: 'udp-v1',
11
+ IROH: 'iroh-v1',
12
+ });
13
+
14
+ export const TRANSPORT_DEFAULT = Object.freeze(['udp']);
15
+
16
+ export const TRANSPORT_CODE = Object.freeze({
17
+ OK: 'TRANSPORT_OK',
18
+ UNAVAILABLE: 'TRANSPORT_UNAVAILABLE',
19
+ BAD_SHAPE: 'TRANSPORT_BAD_SHAPE',
20
+ CLOSED: 'TRANSPORT_CLOSED',
21
+ UNKNOWN: 'TRANSPORT_UNKNOWN',
22
+ });
23
+
24
+ /**
25
+ * Validate the pieces a transport must expose. Returns a named refusal instead
26
+ * of throwing so a registry can skip a broken transport without losing the rest.
27
+ */
28
+ export function assertTransportShape(transport) {
29
+ if (transport === null || typeof transport !== 'object') return { ok: false, code: TRANSPORT_CODE.BAD_SHAPE, detail: 'transport must be an object' };
30
+ for (const name of ['name', 'start', 'stop', 'send', 'onMessage', 'paths', 'diagnostics']) {
31
+ if (name === 'name' ? typeof transport.name !== 'string' : typeof transport[name] !== 'function') {
32
+ return { ok: false, code: TRANSPORT_CODE.BAD_SHAPE, detail: 'missing ' + name };
33
+ }
34
+ }
35
+ return { ok: true };
36
+ }
37
+
38
+ /**
39
+ * Resolve the configured transport list without ever inventing one.
40
+ * Unknown names are dropped and reported; `udp` is always kept as the fallback.
41
+ */
42
+ export function resolveTransports(config = {}) {
43
+ const requested = Array.isArray(config.transports) ? config.transports.filter((name) => typeof name === 'string') : TRANSPORT_DEFAULT;
44
+ const known = new Set(Object.values(TRANSPORT));
45
+ const accepted = [];
46
+ const refused = [];
47
+ for (const name of requested) {
48
+ if (!known.has(name)) { refused.push({ name, code: TRANSPORT_CODE.UNKNOWN }); continue; }
49
+ if (!accepted.includes(name)) accepted.push(name);
50
+ }
51
+ if (!accepted.includes(TRANSPORT.UDP)) accepted.push(TRANSPORT.UDP);
52
+ return { ok: true, transports: accepted, refused };
53
+ }
54
+
55
+ /**
56
+ * Wrap a P2PNode-like object as udp-v1.
57
+ *
58
+ * The node is expected to expose `dial(machineId)` (returns {ok, channel}),
59
+ * `status()` and optionally `close()`. Channels expose `send`, `on`, `close`.
60
+ * This adapter caches one channel per peer so send() is not a dial per frame.
61
+ */
62
+ export function createUdpTransport({ node, name = TRANSPORT.UDP } = {}) {
63
+ if (node === null || typeof node !== 'object' || typeof node.dial !== 'function') {
64
+ return { ok: false, code: TRANSPORT_CODE.BAD_SHAPE, detail: 'createUdpTransport needs a node with dial()' };
65
+ }
66
+ const channels = new Map();
67
+ const handlers = new Set();
68
+ let closed = false;
69
+
70
+ const attach = (peer, channel) => {
71
+ channel.on?.('message', (payload) => {
72
+ for (const handler of handlers) handler({ peer, payload });
73
+ });
74
+ channel.on?.('close', () => channels.delete(peer));
75
+ };
76
+
77
+ const transport = {
78
+ name,
79
+ async start() {
80
+ closed = false;
81
+ return { ok: true, name };
82
+ },
83
+ async stop() {
84
+ closed = true;
85
+ for (const channel of channels.values()) channel.close?.();
86
+ channels.clear();
87
+ node.close?.();
88
+ return { ok: true };
89
+ },
90
+ async send(peer, payload) {
91
+ if (closed) return { ok: false, code: TRANSPORT_CODE.CLOSED };
92
+ let channel = channels.get(peer) ?? null;
93
+ if (channel === null) {
94
+ const dialled = await node.dial(peer);
95
+ if (dialled?.ok !== true) return { ok: false, code: dialled?.code ?? 'UDP_DIAL_FAILED', detail: dialled?.error ?? null };
96
+ channel = dialled.channel;
97
+ channels.set(peer, channel);
98
+ attach(peer, channel);
99
+ }
100
+ await channel.send(payload);
101
+ return { ok: true, peer };
102
+ },
103
+ onMessage(handler) {
104
+ if (typeof handler !== 'function') return () => {};
105
+ handlers.add(handler);
106
+ return () => handlers.delete(handler);
107
+ },
108
+ paths() {
109
+ const status = typeof node.status === 'function' ? node.status() : null;
110
+ return Array.isArray(status?.candidates) ? status.candidates.map((candidate) => ({ ...candidate, transport: name })) : [];
111
+ },
112
+ diagnostics() {
113
+ const status = typeof node.status === 'function' ? node.status() : null;
114
+ return {
115
+ name,
116
+ closed,
117
+ channels: channels.size,
118
+ encryption: status?.encryption ?? null,
119
+ last_error: status?.last_error ?? null,
120
+ };
121
+ },
122
+ };
123
+ return { ok: true, transport };
124
+ }
125
+
126
+ /**
127
+ * Registry: the only object the backend layer will need to talk to later.
128
+ * It keeps a default and can report every registered transport's diagnostics.
129
+ */
130
+ export function createTransportRegistry({ transports = [], defaultName = TRANSPORT.UDP } = {}) {
131
+ const byName = new Map();
132
+ const refused = [];
133
+ for (const transport of transports) {
134
+ const shape = assertTransportShape(transport);
135
+ if (shape.ok !== true) { refused.push({ transport: transport?.name ?? null, code: shape.code, detail: shape.detail }); continue; }
136
+ byName.set(transport.name, transport);
137
+ }
138
+ return {
139
+ ok: true,
140
+ refused,
141
+ names: [...byName.keys()],
142
+ get(name) { return byName.get(name) ?? null; },
143
+ defaultTransport() { return byName.get(defaultName) ?? byName.get(TRANSPORT.UDP) ?? null; },
144
+ diagnostics() { return [...byName.values()].map((transport) => transport.diagnostics()); },
145
+ };
146
+ }
@@ -1,4 +1,4 @@
1
- import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
1
+ import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
2
2
  import { diagnoseFleetBinding, shouldRejoin } from './fleet-binding.mjs';
3
3
  import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
4
4
  /**
@@ -291,7 +291,7 @@ const MAX_STAGES = 16;
291
291
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
292
292
  * overrides it at runtime for tests and packagers.
293
293
  */
294
- const PLUGIN_VERSION = '2.0.8';
294
+ const PLUGIN_VERSION = '2.0.9';
295
295
 
296
296
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
297
297
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -1681,7 +1681,17 @@ async function pushOffer(node, frame, crypto = {}) {
1681
1681
  // no channel at all.
1682
1682
  const dialOptions = session === undefined ? {} : { session };
1683
1683
  try {
1684
- const dialled = await node.dial(frame.machine_id, dialOptions);
1684
+ let dialled = await node.dial(frame.machine_id, { ...dialOptions, peerKey });
1685
+ if (dialled.ok === true
1686
+ && dialled.reused === true
1687
+ && dialled.channel?.encryption?.encrypted !== true
1688
+ && peerKey !== null
1689
+ && typeof node.dropChannel === 'function') {
1690
+ // A plaintext channel from an earlier reverse dial must not win by reuse: drop it and
1691
+ // immediately redial with the peer key, so this offer gets a real handshake.
1692
+ node.dropChannel(dialled.session);
1693
+ dialled = await node.dial(frame.machine_id, { ...dialOptions, peerKey });
1694
+ }
1685
1695
  if (!dialled.ok) {
1686
1696
  return {
1687
1697
  ok: false,
@@ -4998,13 +5008,13 @@ const result = await request({
4998
5008
  };
4999
5009
  })();
5000
5010
 
5001
- const fleetBinding = await diagnoseFleetBinding({
5002
- relayUrl: cfg.rabbitUrl,
5003
- token: device.device_token,
5004
- machineId: device.machine_id,
5005
- fleetKey: cfg.fleetKey,
5006
- });
5007
-
5011
+ const fleetBinding = await diagnoseFleetBinding({
5012
+ relayUrl: cfg.rabbitUrl,
5013
+ token: device.device_token,
5014
+ machineId: device.machine_id,
5015
+ fleetKey: cfg.fleetKey,
5016
+ });
5017
+
5008
5018
  return JSON.stringify(
5009
5019
  {
5010
5020
  ok: true,
@@ -5028,8 +5038,8 @@ const result = await request({
5028
5038
  */
5029
5039
  rabbit_source: cfg.rabbitSource,
5030
5040
  rabbit_url: cfg.rabbitUrl,
5031
- p2p: p2pStatus,
5032
- fleet: fleetBinding,
5041
+ p2p: p2pStatus,
5042
+ fleet: fleetBinding,
5033
5043
  rtt,
5034
5044
  config: {
5035
5045
  rabbitUrl: cfg.rabbitUrl,
@@ -5039,7 +5049,7 @@ const result = await request({
5039
5049
  projectDir: cfg.projectDir,
5040
5050
  autoStartAgent: cfg.autoStartAgent,
5041
5051
  allowedCommands: cfg.allowedCommands,
5042
- pairingCode_configured: cfg.pairingCode !== null,
5052
+ pairingCode_configured: cfg.pairingCode !== null,
5043
5053
  fleetKey_configured: cfg.fleetKey !== null,
5044
5054
  p2pMode: cfg.p2pMode,
5045
5055
  // The resolved list, shared server first, exactly as the node will query it.
@@ -8039,18 +8049,18 @@ async function startLocalStack(ctx, cfg) {
8039
8049
  if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
8040
8050
  ctx.logger?.warn?.('w2m: stored device token was rejected by ' + relayForToken + ' (HTTP ' + probe.status + '); pairing again with the fleet key');
8041
8051
  }
8042
- if (alreadyPaired && fleetKeyFromConfig !== null) {
8043
- const binding = await diagnoseFleetBinding({
8044
- relayUrl: relayForToken,
8045
- token: existingToken,
8046
- machineId: loaded.identity.machine_id,
8047
- fleetKey: fleetKeyFromConfig,
8048
- });
8049
- if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: fleetKeyFromConfig })) {
8050
- alreadyPaired = false;
8051
- ctx.logger?.warn?.('w2m: FLEET_UNBOUND (token valid, fleet_id=null); re-admitting with the configured fleet key');
8052
- }
8053
- }
8052
+ if (alreadyPaired && fleetKeyFromConfig !== null) {
8053
+ const binding = await diagnoseFleetBinding({
8054
+ relayUrl: relayForToken,
8055
+ token: existingToken,
8056
+ machineId: loaded.identity.machine_id,
8057
+ fleetKey: fleetKeyFromConfig,
8058
+ });
8059
+ if (shouldRejoin({ token: existingToken, fleetId: binding.fleet_id, fleetKey: fleetKeyFromConfig })) {
8060
+ alreadyPaired = false;
8061
+ ctx.logger?.warn?.('w2m: FLEET_UNBOUND (token valid, fleet_id=null); re-admitting with the configured fleet key');
8062
+ }
8063
+ }
8054
8064
  } catch { /* network error: keep the token, do not force a re-pair */ }
8055
8065
  }
8056
8066
  }