@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -5
- package/TEST-CASES-JOINT-FLEET-2.0.8.md +119 -0
- package/TEST-PLAN-JOINT-FLEET-2.0.8.md +99 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/DSH-UPGRADE-2.0.8.md +129 -0
- package/docs/TRANSFER-LAYER.md +162 -0
- package/docs/acceptance/2.0.8/RELEASE-2080.json +21 -0
- package/docs/acceptance/2.0.9/RELEASE-2090.json +24 -0
- package/docs/adr/0002-p2p-transport.md +67 -0
- package/docs/plans/IM2A-2.2.2-PLAN.md +293 -0
- package/lib/client.js +30 -4
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +7 -0
- package/src/agent/chacha.mjs +93 -0
- package/src/agent/crypto.mjs +64 -29
- package/src/agent/discovery.mjs +81 -0
- package/src/agent/iroh-sidecar.mjs +149 -0
- package/src/agent/p2p-node.mjs +11 -1
- package/src/agent/p2p-race.mjs +105 -0
- package/src/agent/p2p.mjs +105 -18
- package/src/agent/path-scheduler.mjs +106 -0
- package/src/agent/transfer/artifact-ref-v3.mjs +93 -0
- package/src/agent/transfer/ferry.mjs +206 -0
- package/src/agent/transfer/index.mjs +4 -0
- package/src/agent/transfer/manifest.mjs +92 -0
- package/src/agent/transfer/object-store.mjs +144 -0
- package/src/agent/transfer/publish.mjs +76 -0
- package/src/agent/transfer/reference.mjs +97 -0
- package/src/agent/transfer/router.mjs +80 -0
- package/src/agent/transfer/want-block.mjs +135 -0
- package/src/agent/transport.mjs +146 -0
- package/src/game/xiangqi.mjs +217 -0
- package/src/plugin/fleet-binding.mjs +105 -0
- package/src/plugin/tools.mjs +34 -2
package/src/agent/crypto.mjs
CHANGED
|
@@ -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
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1657
|
-
|
|
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
|
+
}
|
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -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
|
+
}
|