@twinsearth/w2m-dsh-plugin 2.0.7 → 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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -5
  4. package/TEST-CASES-JOINT-FLEET-2.0.8.md +119 -0
  5. package/TEST-PLAN-JOINT-FLEET-2.0.8.md +99 -0
  6. package/codex/mcp-servers.toml +1 -1
  7. package/docs/DSH-UPGRADE-2.0.8.md +129 -0
  8. package/docs/TRANSFER-LAYER.md +162 -0
  9. package/docs/acceptance/2.0.8/RELEASE-2080.json +21 -0
  10. package/docs/acceptance/2.0.9/RELEASE-2090.json +24 -0
  11. package/docs/adr/0002-p2p-transport.md +67 -0
  12. package/docs/plans/IM2A-2.2.2-PLAN.md +293 -0
  13. package/lib/client.js +30 -4
  14. package/package.json +5 -3
  15. package/plugin.json +1 -1
  16. package/src/agent/agent.mjs +7 -0
  17. package/src/agent/chacha.mjs +93 -0
  18. package/src/agent/crypto.mjs +64 -29
  19. package/src/agent/discovery.mjs +81 -0
  20. package/src/agent/iroh-sidecar.mjs +149 -0
  21. package/src/agent/p2p-node.mjs +11 -1
  22. package/src/agent/p2p-race.mjs +105 -0
  23. package/src/agent/p2p.mjs +105 -18
  24. package/src/agent/path-scheduler.mjs +106 -0
  25. package/src/agent/transfer/artifact-ref-v3.mjs +93 -0
  26. package/src/agent/transfer/ferry.mjs +206 -0
  27. package/src/agent/transfer/index.mjs +4 -0
  28. package/src/agent/transfer/manifest.mjs +92 -0
  29. package/src/agent/transfer/object-store.mjs +144 -0
  30. package/src/agent/transfer/publish.mjs +76 -0
  31. package/src/agent/transfer/reference.mjs +97 -0
  32. package/src/agent/transfer/router.mjs +80 -0
  33. package/src/agent/transfer/want-block.mjs +135 -0
  34. package/src/agent/transport.mjs +146 -0
  35. package/src/game/xiangqi.mjs +217 -0
  36. package/src/plugin/fleet-binding.mjs +105 -0
  37. package/src/plugin/tools.mjs +34 -2
@@ -53,6 +53,8 @@ import {
53
53
  randomBytes,
54
54
  timingSafeEqual,
55
55
  } from 'node:crypto';
56
+
57
+ import { jsOpen, jsSeal } from './chacha.mjs';
56
58
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
57
59
  import { dirname, isAbsolute, join, resolve } from 'node:path';
58
60
  import { blake2s256 as blake2s256Js } from './blake2s.mjs';
@@ -758,6 +760,57 @@ export function derive(ikm, label, length = KEY_BYTES) {
758
760
  *
759
761
  * @returns {{ ok: boolean, missing: string[], present: string[], node: string, detail: string }}
760
762
  */
763
+ /** Wrap a cipher construction so an OpenSSL "Unknown cipher" carries WHO/WHAT, not just the text. */
764
+ function cipherContextError(kind, cipherName, error, context = {}) {
765
+ const wrapped = new Error('P2P_CIPHER_UNAVAILABLE: ' + kind + ' ' + cipherName + ' failed: ' + (error?.message ?? String(error)));
766
+ wrapped.code = 'P2P_CIPHER_UNAVAILABLE';
767
+ wrapped.detail = {
768
+ cipher: cipherName,
769
+ suite: typeof context.suite === 'string' ? context.suite : 'w2m-p2p-v1',
770
+ peerKey: typeof context.peerKey === 'string' ? context.peerKey : null,
771
+ keyLength: context.keyLength ?? null,
772
+ ivLength: context.ivLength ?? null,
773
+ openssl: error?.message ?? String(error),
774
+ node: process.version,
775
+ };
776
+ wrapped.cause = error;
777
+ return wrapped;
778
+ }
779
+ function createCipherContext(name, key, iv, options, context = {}) {
780
+ try { return createCipheriv(name, key, iv, options); } catch (error) { throw cipherContextError('encrypt', name, error, { ...context, keyLength: key?.length, ivLength: iv?.length }); }
781
+ }
782
+ function createDecipherContext(name, key, iv, options, context = {}) {
783
+ try { return createDecipheriv(name, key, iv, options); } catch (error) { throw cipherContextError('decrypt', name, error, { ...context, keyLength: key?.length, ivLength: iv?.length }); }
784
+ }
785
+
786
+
787
+ /** Native ChaCha20-Poly1305 availability (DSH/Electron's BoringSSL may not expose it). */
788
+ let NATIVE_CHACHA = true;
789
+ try {
790
+ const probeKey = Buffer.alloc(32, 1);
791
+ const probeCipher = createCipheriv('chacha20-poly1305', probeKey, Buffer.alloc(12), { authTagLength: 16 });
792
+ probeCipher.final();
793
+ } catch {
794
+ NATIVE_CHACHA = false;
795
+ }
796
+ export function nativeAeadAvailable() { return NATIVE_CHACHA; }
797
+ function aeadSeal(key, nonce, plaintext, aad = Buffer.alloc(0), context = {}) {
798
+ if (!NATIVE_CHACHA) return jsSeal(key, nonce, plaintext, aad);
799
+ const cipher = createCipherContext('chacha20-poly1305', key, nonce, { authTagLength: TAG_BYTES }, { suite: 'w2m-p2p-v1', ...context });
800
+ if (aad.length > 0) cipher.setAAD(aad);
801
+ return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
802
+ }
803
+ function aeadOpen(key, nonce, sealed, aad = Buffer.alloc(0)) {
804
+ if (!NATIVE_CHACHA) return jsOpen(key, nonce, sealed, aad);
805
+ if (!Buffer.isBuffer(sealed) || sealed.length < TAG_BYTES) return null;
806
+ try {
807
+ const decipher = createDecipherContext('chacha20-poly1305', key, nonce, { authTagLength: TAG_BYTES }, { suite: 'w2m-p2p-v1' });
808
+ if (aad.length > 0) decipher.setAAD(aad);
809
+ decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
810
+ return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
811
+ } catch { return null; }
812
+ }
813
+
761
814
  export function cryptoCapabilities() {
762
815
  const present = [];
763
816
  const missing = [];
@@ -774,11 +827,11 @@ export function cryptoCapabilities() {
774
827
  check('blake2s256', () => blake2s256Support().ok);
775
828
  check('sha256', () => createHash('sha256').update('').digest().length === 32);
776
829
  check('chacha20-poly1305', () => {
830
+ // Count the JS fallback as available: what the handshake needs is the AEAD, not OpenSSL's copy.
777
831
  const key = randomBytes(32);
778
- const cipher = createCipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: 16 });
779
- cipher.update(Buffer.from('probe'));
780
- cipher.final();
781
- return cipher.getAuthTag().length === 16;
832
+ const sealed = aeadSeal(key, Buffer.alloc(12), Buffer.from('probe'));
833
+ const opened = aeadOpen(key, Buffer.alloc(12), sealed);
834
+ return sealed.length === 21 && opened !== null && opened.toString('utf8') === 'probe';
782
835
  });
783
836
  check('hkdf-sha256', () => hkdfSync('sha256', randomBytes(32), Buffer.alloc(0), Buffer.from('probe'), 32).byteLength === 32);
784
837
  check('x25519+diffieHellman', () => {
@@ -993,10 +1046,8 @@ export function sessionKeys({ ephemeralShared, initiatorStaticShared, responderS
993
1046
  * @param {Buffer} message
994
1047
  * @returns {Buffer} Ciphertext followed by the tag: 48 bytes for a 32-byte message.
995
1048
  */
996
- export function sealMac(key, message) {
997
- const cipher = createCipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: TAG_BYTES });
998
- const ciphertext = Buffer.concat([cipher.update(message), cipher.final()]);
999
- return Buffer.concat([ciphertext, cipher.getAuthTag()]);
1049
+ export function sealMac(key, message, context = {}) {
1050
+ return aeadSeal(key, Buffer.alloc(12), message, Buffer.alloc(0), context);
1000
1051
  }
1001
1052
 
1002
1053
  /**
@@ -1008,13 +1059,7 @@ export function sealMac(key, message) {
1008
1059
  */
1009
1060
  export function openMac(key, sealed) {
1010
1061
  if (!Buffer.isBuffer(sealed) || sealed.length <= TAG_BYTES) return null;
1011
- try {
1012
- const decipher = createDecipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: TAG_BYTES });
1013
- decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
1014
- return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
1015
- } catch {
1016
- return null;
1017
- }
1062
+ return aeadOpen(key, Buffer.alloc(12), sealed);
1018
1063
  }
1019
1064
 
1020
1065
  /** @param {Buffer} a @param {Buffer} b @returns {boolean} Constant-time comparison. */
@@ -1600,13 +1645,11 @@ export class CryptoSession {
1600
1645
  cipherHeader.writeUInt8(this.sendEpoch & 0xff, 1);
1601
1646
  cipherHeader.writeBigUInt64LE(counter, 2);
1602
1647
 
1603
- const cipher = createCipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
1604
1648
  // The frame's own header —kind and session —rides as additional authenticated data. That is
1605
1649
  // what stops an attacker retagging a DATA frame as a BYE, or moving a frame into another session:
1606
1650
  // the tag covers the fields the receiving channel routes on.
1607
- cipher.setAAD(header);
1608
- const ciphertext = Buffer.concat([cipher.update(frame.subarray(FRAME_HEADER_BYTES)), cipher.final()]);
1609
- return Buffer.concat([header, cipherHeader, ciphertext, cipher.getAuthTag()]);
1651
+ const sealed = aeadSeal(key, nonceFor(counter), frame.subarray(FRAME_HEADER_BYTES), header);
1652
+ return Buffer.concat([header, cipherHeader, sealed]);
1610
1653
  }
1611
1654
 
1612
1655
  /**
@@ -1653,16 +1696,8 @@ export class CryptoSession {
1653
1696
  const key = this.receiveKey(isInitiator, epoch);
1654
1697
  if (key === null) return fail(CRYPTO_REASON.EPOCH_TOO_FAR);
1655
1698
 
1656
- let plaintext;
1657
- try {
1658
- const decipher = createDecipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
1659
- decipher.setAAD(header);
1660
- decipher.setAuthTag(body.subarray(body.length - TAG_BYTES));
1661
- plaintext = Buffer.concat([
1662
- decipher.update(body.subarray(CIPHER_HEADER_BYTES, body.length - TAG_BYTES)),
1663
- decipher.final(),
1664
- ]);
1665
- } catch {
1699
+ const plaintext = aeadOpen(key, nonceFor(counter), body.subarray(CIPHER_HEADER_BYTES), header);
1700
+ if (plaintext === null) {
1666
1701
  // Authentication failed. The frame is dropped and never processed, and the tag is *not* retried
1667
1702
  // against the other epochs: an attacker who could make us try keys until one verified would
1668
1703
  // have turned a 128-bit tag into a search.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * W3 discovery seam: providers only produce candidate addresses.
3
+ *
4
+ * Current order is relay -> mDNS -> DHT. A candidate is never an authorisation:
5
+ * the dial path still verifies the peer's static key and the task identity.
6
+ * Adding a DHT sidecar later means implementing the same `discover(machineId)`
7
+ * shape; it does not change the transport or the relay.
8
+ */
9
+
10
+ export const DISCOVERY_SOURCE = Object.freeze({
11
+ RELAY: 'relay',
12
+ MDNS: 'mdns',
13
+ DHT: 'dht',
14
+ });
15
+
16
+ /** Order used only to break duplicate-address ties, not to trust a source. */
17
+ const SOURCE_RANK = Object.freeze({
18
+ [DISCOVERY_SOURCE.RELAY]: 0,
19
+ [DISCOVERY_SOURCE.MDNS]: 1,
20
+ [DISCOVERY_SOURCE.DHT]: 2,
21
+ });
22
+
23
+ const usable = (candidate) => candidate !== null
24
+ && typeof candidate === 'object'
25
+ && typeof candidate.address === 'string'
26
+ && candidate.address !== ''
27
+ && candidate.address !== '0.0.0.0'
28
+ && candidate.address !== '::'
29
+ && Number.isInteger(candidate.port)
30
+ && candidate.port > 0
31
+ && candidate.port <= 65535;
32
+
33
+ /**
34
+ * Merge candidates from every provider, deduplicating by address:port.
35
+ * The first source in rank order wins the label; the address itself is kept
36
+ * once. No provider can inject a non-address payload into this list.
37
+ */
38
+ export function mergeCandidates(lists, { limit = 8 } = {}) {
39
+ const merged = [];
40
+ const seen = new Set();
41
+ const sorted = [...lists].sort((a, b) => (SOURCE_RANK[a.source] ?? 9) - (SOURCE_RANK[b.source] ?? 9));
42
+ for (const list of sorted) {
43
+ for (const candidate of Array.isArray(list.candidates) ? list.candidates : []) {
44
+ if (!usable(candidate)) continue;
45
+ const key = candidate.address + ':' + candidate.port;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ merged.push({ address: candidate.address, port: candidate.port, source: list.source ?? candidate.source ?? 'unknown' });
49
+ if (merged.length >= limit) return merged;
50
+ }
51
+ }
52
+ return merged;
53
+ }
54
+
55
+ /** Relay discovery, built on the existing authenticated /v1/peer route. */
56
+ export function createRelayDiscovery({ getJson } = {}) {
57
+ if (typeof getJson !== 'function') return { ok: false, code: 'DISCOVERY_NO_GET_JSON' };
58
+ return {
59
+ ok: true,
60
+ source: DISCOVERY_SOURCE.RELAY,
61
+ async discover(machineId) {
62
+ if (typeof machineId !== 'string' || machineId === '') return { ok: false, code: 'DISCOVERY_BAD_MACHINE_ID', candidates: [] };
63
+ const answer = await getJson('/v1/peer/' + encodeURIComponent(machineId));
64
+ if (answer?.ok !== true || answer?.status === 404) return { ok: false, code: 'DISCOVERY_RELAY_MISS', candidates: [] };
65
+ const candidates = Array.isArray(answer?.json?.peer?.candidates) ? answer.json.peer.candidates : [];
66
+ return { ok: true, source: DISCOVERY_SOURCE.RELAY, candidates, staticKey: answer?.json?.peer?.static_key ?? null };
67
+ },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * A provider for tests and for an injected mDNS implementation. It carries no
73
+ * network logic, so the caller decides how candidates were observed.
74
+ */
75
+ export function createStaticDiscovery({ source, candidates = [] } = {}) {
76
+ return {
77
+ ok: true,
78
+ source: source ?? 'static',
79
+ async discover() { return { ok: true, source: source ?? 'static', candidates: candidates.slice() }; },
80
+ };
81
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * W1 spike: iroh sidecar transport (JSON over length-prefixed stdio).
3
+ *
4
+ * The real binary is intentionally not bundled or auto-downloaded: the sidecar
5
+ * command/args come from configuration and are verified by the caller. This
6
+ * module owns only the process lifecycle and the frame contract, so a fake
7
+ * sidecar can exercise every failure path in CI without Rust or a TLS relay.
8
+ *
9
+ * Wire (parent -> child and child -> parent): 4-byte big-endian length + UTF-8 JSON.
10
+ * { id, method, params } request
11
+ * { id, result } / { id, error } response
12
+ * { type: 'ready' } child is listening
13
+ * { type: 'message', peer, data } inbound payload (base64)
14
+ * { type: 'status', paths, ... } diagnostics
15
+ */
16
+ import { spawn as nodeSpawn } from 'node:child_process';
17
+
18
+ export const IROH_TRANSPORT = 'iroh-v1';
19
+ export const IROH_CODE = Object.freeze({
20
+ START_FAILED: 'IROH_SIDECAR_START_FAILED',
21
+ TIMEOUT: 'IROH_TIMEOUT',
22
+ EXITED: 'IROH_SIDECAR_EXITED',
23
+ CLOSED: 'IROH_CLOSED',
24
+ BAD_FRAME: 'IROH_BAD_FRAME',
25
+ });
26
+
27
+ function encode(value) {
28
+ const body = Buffer.from(JSON.stringify(value), 'utf8');
29
+ const header = Buffer.alloc(4);
30
+ header.writeUInt32BE(body.length);
31
+ return Buffer.concat([header, body]);
32
+ }
33
+
34
+ export function createIrohSidecarTransport({ command, args = [], env = {}, timeoutMs = 2500, spawnImpl = nodeSpawn } = {}) {
35
+ if (typeof command !== 'string' || command === '') {
36
+ return { ok: false, code: IROH_CODE.START_FAILED, detail: 'iroh sidecar command is required' };
37
+ }
38
+ const handlers = new Set();
39
+ const pending = new Map();
40
+ let child = null;
41
+ let buffer = Buffer.alloc(0);
42
+ let nextId = 1;
43
+ let ready = null;
44
+ let closed = false;
45
+ let lastStatus = { paths: [] };
46
+
47
+ const failPending = (code, detail) => {
48
+ for (const [id, entry] of pending) {
49
+ clearTimeout(entry.timer);
50
+ entry.reject(Object.assign(new Error(detail ?? code), { code }));
51
+ pending.delete(id);
52
+ }
53
+ };
54
+
55
+ const onFrame = (message) => {
56
+ if (message?.type === 'ready') { ready?.(); ready = null; return; }
57
+ if (message?.type === 'message') {
58
+ const payload = Buffer.from(String(message.data ?? ''), 'base64');
59
+ for (const handler of handlers) handler({ peer: message.peer ?? null, payload });
60
+ return;
61
+ }
62
+ if (message?.type === 'status') { lastStatus = { paths: Array.isArray(message.paths) ? message.paths : [] }; return; }
63
+ if (typeof message?.id === 'number' && pending.has(message.id)) {
64
+ const entry = pending.get(message.id);
65
+ clearTimeout(entry.timer);
66
+ pending.delete(message.id);
67
+ if (message.error) entry.reject(Object.assign(new Error(String(message.error.message ?? message.error)), { code: message.error.code ?? IROH_CODE.EXITED }));
68
+ else entry.resolve(message.result ?? null);
69
+ }
70
+ };
71
+
72
+ const onData = (chunk) => {
73
+ buffer = Buffer.concat([buffer, chunk]);
74
+ while (buffer.length >= 4) {
75
+ const length = buffer.readUInt32BE(0);
76
+ if (buffer.length < 4 + length) return;
77
+ const body = buffer.subarray(4, 4 + length);
78
+ buffer = buffer.subarray(4 + length);
79
+ try { onFrame(JSON.parse(body.toString('utf8'))); } catch { /* a bad frame is dropped, not fatal */ }
80
+ }
81
+ };
82
+
83
+ const rpc = (method, params = {}) => new Promise((resolve, reject) => {
84
+ if (closed || child === null) { reject(Object.assign(new Error(IROH_CODE.CLOSED), { code: IROH_CODE.CLOSED })); return; }
85
+ const id = nextId;
86
+ nextId += 1;
87
+ const timer = setTimeout(() => {
88
+ pending.delete(id);
89
+ reject(Object.assign(new Error(IROH_CODE.TIMEOUT), { code: IROH_CODE.TIMEOUT }));
90
+ }, timeoutMs);
91
+ timer.unref?.();
92
+ pending.set(id, { resolve, reject, timer });
93
+ child.stdin.write(encode({ id, method, params }));
94
+ });
95
+
96
+ const transport = {
97
+ name: IROH_TRANSPORT,
98
+ async start() {
99
+ return new Promise((resolve) => {
100
+ try {
101
+ child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, ...env } });
102
+ } catch (error) {
103
+ resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error instanceof Error ? error.message : String(error) });
104
+ return;
105
+ }
106
+ const timer = setTimeout(() => { resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: IROH_CODE.TIMEOUT }); }, timeoutMs);
107
+ ready = () => { clearTimeout(timer); resolve({ ok: true, name: IROH_TRANSPORT }); };
108
+ child.stdout.on('data', onData);
109
+ child.stderr.on('data', () => {});
110
+ child.on('exit', () => {
111
+ closed = true;
112
+ clearTimeout(timer);
113
+ failPending(IROH_CODE.EXITED, 'iroh sidecar exited');
114
+ if (ready !== null) { ready = null; resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: 'sidecar exited before ready' }); }
115
+ });
116
+ child.on('error', (error) => {
117
+ closed = true;
118
+ clearTimeout(timer);
119
+ resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error.message });
120
+ });
121
+ });
122
+ },
123
+ async send(peer, payload) {
124
+ if (closed || child === null) return { ok: false, code: IROH_CODE.CLOSED };
125
+ try {
126
+ await rpc('send', { peer, data: Buffer.from(payload).toString('base64') });
127
+ return { ok: true, peer };
128
+ } catch (error) {
129
+ return { ok: false, code: error.code ?? IROH_CODE.EXITED, detail: error.message };
130
+ }
131
+ },
132
+ onMessage(handler) {
133
+ if (typeof handler !== 'function') return () => {};
134
+ handlers.add(handler);
135
+ return () => handlers.delete(handler);
136
+ },
137
+ paths() { return lastStatus.paths.map((path) => ({ ...path, transport: IROH_TRANSPORT })); },
138
+ diagnostics() { return { name: IROH_TRANSPORT, closed, pending: pending.size, paths: lastStatus.paths.length }; },
139
+ async stop() {
140
+ if (closed) return { ok: true };
141
+ closed = true;
142
+ failPending(IROH_CODE.CLOSED, 'transport stopped');
143
+ try { child?.stdin.end(); child?.kill(); } catch { /* already gone */ }
144
+ child = null;
145
+ return { ok: true };
146
+ },
147
+ };
148
+ return { ok: true, transport };
149
+ }
@@ -1232,7 +1232,7 @@ export class P2PNode extends EventEmitter {
1232
1232
  * that publishes no key is genuinely an older build and `PLAINTEXT_PEER` is honest; a peer that
1233
1233
  * publishes one we had not learned is *our* gap, and after this it is not a gap at all.
1234
1234
  */
1235
- let peerKey = this.peerKeys.get(machineId);
1235
+ let peerKey = typeof options.peerKey === 'string' && options.peerKey !== '' ? options.peerKey : this.peerKeys.get(machineId);
1236
1236
  if (peerKey === null && announcedStaticKey !== null) {
1237
1237
  this.peerKeys.set(machineId, announcedStaticKey, 'relay');
1238
1238
  peerKey = announcedStaticKey;
@@ -1497,6 +1497,16 @@ export class P2PNode extends EventEmitter {
1497
1497
  * @param {import('./p2p.mjs').P2PChannel} channel
1498
1498
  * @param {{from:string|null, session:number}} details
1499
1499
  */
1500
+ /** Close and forget a cached channel so a fresh dial can arm a new handshake. */
1501
+ dropChannel(session) {
1502
+ const channel = this.channels.get(session);
1503
+ if (channel === undefined) return false;
1504
+ this.channels.delete(session);
1505
+ this.claimed.delete(session);
1506
+ try { channel.close('replaced-by-rehandshake', { silent: true }); } catch { /* already gone */ }
1507
+ return true;
1508
+ }
1509
+
1500
1510
  adoptChannel(channel, details) {
1501
1511
  const previous = this.channels.get(details.session);
1502
1512
  if (previous !== undefined && previous !== channel) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Multi-transport race: capability negotiation + first-usable-path wins.
3
+ *
4
+ * The control plane never waits for this. A dispatch goes through the relay
5
+ * immediately; this race only decides which data-channel carries results.
6
+ * There is deliberately no relay fallback here: if every transport loses, the
7
+ * caller gets ALL_TRANSPORTS_FAILED and the task result stays on the control
8
+ * plane, which is a different (and already-working) path.
9
+ */
10
+
11
+ export const TRANSPORT_PRIORITY = Object.freeze(['iroh', 'udp']);
12
+
13
+ /**
14
+ * Intersect the local and remote capability lists.
15
+ * A peer that declares nothing is an older build and gets the UDP path only.
16
+ *
17
+ * @param {{local?: string[], remote?: string[]|null, priority?: string[]}} input
18
+ * @returns {string[]} ordered transport names both ends can attempt
19
+ */
20
+ export function negotiateTransports({ local = ['udp'], remote = null, priority = TRANSPORT_PRIORITY } = {}) {
21
+ const localSet = new Set(local.filter((name) => typeof name === 'string'));
22
+ const remoteSet = remote === null ? new Set(['udp']) : new Set(remote.filter((name) => typeof name === 'string'));
23
+ return priority.filter((name) => localSet.has(name) && remoteSet.has(name));
24
+ }
25
+
26
+ /**
27
+ * Run every candidate's start() concurrently and resolve with the first channel.
28
+ * The losers are aborted as soon as a winner exists (or the total budget expires).
29
+ *
30
+ * @param {{
31
+ * candidates: Array<{name: string, timeoutMs?: number, start: (ctx: {signal: AbortSignal}) => Promise<any>}>,
32
+ * budgetMs?: number,
33
+ * now?: () => number,
34
+ * }} input
35
+ * @returns {Promise<{ok: true, name: string, channel: any, attempts: object[], elapsedMs: number}
36
+ * | {ok: false, code: 'ALL_TRANSPORTS_FAILED'|'NO_TRANSPORTS', attempts: object[], elapsedMs: number}>}
37
+ */
38
+ export async function raceTransports({ candidates, budgetMs = 3000, now = Date.now } = {}) {
39
+ if (!Array.isArray(candidates) || candidates.length === 0) {
40
+ return { ok: false, code: 'NO_TRANSPORTS', attempts: [], elapsedMs: 0 };
41
+ }
42
+ const startedAt = now();
43
+ const attempts = [];
44
+ const controllers = new Map();
45
+ let settled = false;
46
+ let resolveRace = null;
47
+ const race = new Promise((resolve) => { resolveRace = resolve; });
48
+
49
+ const finish = (result) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ for (const [name, controller] of controllers.entries()) {
53
+ if (result.ok === true && result.name === name) continue; // the winner keeps its channel
54
+ controller.abort();
55
+ }
56
+ resolveRace({ ...result, elapsedMs: now() - startedAt });
57
+ };
58
+
59
+ const budgetTimer = setTimeout(() => {
60
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
61
+ }, budgetMs);
62
+ budgetTimer.unref?.();
63
+
64
+ for (const candidate of candidates) {
65
+ if (typeof candidate?.start !== 'function') continue;
66
+ const controller = new AbortController();
67
+ controllers.set(candidate.name, controller);
68
+ const record = { name: candidate.name, ok: false, code: null, elapsedMs: null };
69
+ attempts.push(record);
70
+ const started = now();
71
+ const timer = setTimeout(() => {
72
+ record.code = 'TRANSPORT_TIMEOUT';
73
+ record.elapsedMs = now() - started;
74
+ controller.abort();
75
+ if (attempts.every((attempt) => attempt.elapsedMs !== null) && attempts.every((attempt) => attempt.ok !== true)) {
76
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
77
+ }
78
+ }, Number.isFinite(candidate.timeoutMs) ? candidate.timeoutMs : budgetMs);
79
+ timer.unref?.();
80
+ Promise.resolve()
81
+ .then(() => candidate.start({ signal: controller.signal }))
82
+ .then((channel) => {
83
+ clearTimeout(timer);
84
+ if (settled) return;
85
+ record.ok = true;
86
+ record.elapsedMs = now() - started;
87
+ finish({ ok: true, name: candidate.name, channel, attempts });
88
+ })
89
+ .catch((error) => {
90
+ clearTimeout(timer);
91
+ if (settled) return;
92
+ record.code = error?.code ?? 'TRANSPORT_FAILED';
93
+ record.message = error instanceof Error ? error.message : String(error);
94
+ record.elapsedMs = now() - started;
95
+ if (attempts.every((attempt) => attempt.ok !== true && attempt.elapsedMs !== null)) {
96
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
97
+ }
98
+ });
99
+ }
100
+
101
+ const result = await race;
102
+ clearTimeout(budgetTimer);
103
+ if (result.ok !== true) return result;
104
+ return result;
105
+ }