@twinsearth/w2m-dsh-plugin 2.0.8 → 2.0.10
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 +13 -0
- package/TEST-CASES-JOINT-FLEET-2.0.8.md +119 -0
- package/TEST-PLAN-JOINT-FLEET-2.0.8.md +99 -0
- package/bin/remote-s2-capture.mjs +116 -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.10/RELEASE-2100.json +19 -0
- package/docs/acceptance/2.0.9/RELEASE-2090.json +24 -0
- package/docs/adr/0002-p2p-transport.md +67 -0
- package/docs/plans/REMOTE-DESKTOP-2.1.0.md +153 -0
- package/lib/client.js +29 -3
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +7 -0
- 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 +12 -1
- package/src/agent/transfer/router.mjs +80 -0
- package/src/agent/transfer/want-block.mjs +46 -0
- package/src/agent/transport.mjs +146 -0
- package/src/plugin/tools.mjs +35 -25
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional ArtifactRef v3 (feature-flagged; v2 remains the default on the wire).
|
|
3
|
+
*
|
|
4
|
+
* v3 adds an explicit hash algorithm, a Merkle root and a SHA-256 root so a
|
|
5
|
+
* reader can cross-check the two aggregations. BLAKE3 is not bundled and Node
|
|
6
|
+
* has no native implementation: when it is unavailable, creation falls back to
|
|
7
|
+
* SHA-256 and records that decision instead of pretending BLAKE3 was used.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
export const REF_V3_VERSION = 3;
|
|
12
|
+
|
|
13
|
+
export const REF_V3_CODE = Object.freeze({
|
|
14
|
+
OK: 'REF_V3_OK',
|
|
15
|
+
BAD_SHAPE: 'REF_V3_BAD_SHAPE',
|
|
16
|
+
UNSUPPORTED_ALG: 'REF_V3_UNSUPPORTED_ALG',
|
|
17
|
+
BLAKE3_UNAVAILABLE: 'XFER_BLAKE3_UNAVAILABLE',
|
|
18
|
+
INCONSISTENT: 'REF_V3_INCONSISTENT',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const sha256 = (buffer) => createHash('sha256').update(buffer).digest();
|
|
22
|
+
const hex64 = (value) => {
|
|
23
|
+
if (typeof value !== 'string') return null;
|
|
24
|
+
const raw = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value;
|
|
25
|
+
return /^[0-9a-f]{64}$/.test(raw) ? raw : null;
|
|
26
|
+
};
|
|
27
|
+
const isHex64 = (value) => hex64(value) !== null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Merkle root over chunk digests: leaves are the existing 32-byte chunk hashes;
|
|
31
|
+
* internal nodes are SHA-256(0x01 || left || right); the final root is
|
|
32
|
+
* SHA-256(0x02 || root). An odd node is promoted, never duplicated.
|
|
33
|
+
*/
|
|
34
|
+
export function merkleRootSha256(chunkHashesHex) {
|
|
35
|
+
if (!Array.isArray(chunkHashesHex) || chunkHashesHex.length === 0) return null;
|
|
36
|
+
let level = chunkHashesHex.map((hex) => {
|
|
37
|
+
if (!isHex64(hex)) throw new TypeError('chunk hash must be 64 lowercase hex characters');
|
|
38
|
+
return Buffer.from(hex, 'hex');
|
|
39
|
+
});
|
|
40
|
+
while (level.length > 1) {
|
|
41
|
+
const next = [];
|
|
42
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
43
|
+
if (i + 1 >= level.length) { next.push(level[i]); continue; }
|
|
44
|
+
next.push(sha256(Buffer.concat([Buffer.from([0x01]), level[i], level[i + 1]])));
|
|
45
|
+
}
|
|
46
|
+
level = next;
|
|
47
|
+
}
|
|
48
|
+
return sha256(Buffer.concat([Buffer.from([0x02]), level[0]])).toString('hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{rootCid: string, sha256Root: string, merkleRoot: string, hashAlg?: string,
|
|
53
|
+
* chunkBytes: number, chunkCount: number, size: number, originPeer?: string|null,
|
|
54
|
+
* blake3Available?: boolean}} input
|
|
55
|
+
* @returns {{ok: true, ref: object} | {ok: false, code: string, detail?: string}}
|
|
56
|
+
*/
|
|
57
|
+
export function createRefV3(input) {
|
|
58
|
+
const requestedAlg = input?.hashAlg === 'blake3' ? 'blake3' : 'sha256';
|
|
59
|
+
const effectiveAlg = requestedAlg === 'blake3' && input?.blake3Available !== true ? 'sha256' : requestedAlg;
|
|
60
|
+
const ref = {
|
|
61
|
+
version: REF_V3_VERSION,
|
|
62
|
+
rootCid: hex64(input?.rootCid) ?? input?.rootCid,
|
|
63
|
+
sha256Root: hex64(input?.sha256Root) ?? input?.sha256Root,
|
|
64
|
+
merkleRoot: input?.merkleRoot,
|
|
65
|
+
hashAlg: effectiveAlg,
|
|
66
|
+
...(effectiveAlg !== requestedAlg ? { fallbackFrom: requestedAlg } : {}),
|
|
67
|
+
chunkBytes: input?.chunkBytes,
|
|
68
|
+
chunkCount: input?.chunkCount,
|
|
69
|
+
size: input?.size,
|
|
70
|
+
originPeer: typeof input?.originPeer === 'string' ? input.originPeer : null,
|
|
71
|
+
};
|
|
72
|
+
return verifyRefV3(ref, { blake3Available: input?.blake3Available === true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate v3 shape and invariants. A BLAKE3 v3 ref cannot be verified on a
|
|
77
|
+
* runtime without BLAKE3: that is named, not silently downgraded.
|
|
78
|
+
*/
|
|
79
|
+
export function verifyRefV3(ref, { blake3Available = false } = {}) {
|
|
80
|
+
if (ref === null || typeof ref !== 'object') return { ok: false, code: REF_V3_CODE.BAD_SHAPE, detail: 'ref must be an object' };
|
|
81
|
+
if (ref.version !== REF_V3_VERSION) return { ok: false, code: REF_V3_CODE.BAD_SHAPE, detail: 'version must be 3' };
|
|
82
|
+
if (!isHex64(ref.rootCid) || !isHex64(ref.sha256Root) || !isHex64(ref.merkleRoot)) {
|
|
83
|
+
return { ok: false, code: REF_V3_CODE.BAD_SHAPE, detail: 'rootCid, sha256Root and merkleRoot must be 64-hex' };
|
|
84
|
+
}
|
|
85
|
+
if (ref.hashAlg !== 'sha256' && ref.hashAlg !== 'blake3') return { ok: false, code: REF_V3_CODE.UNSUPPORTED_ALG, detail: String(ref.hashAlg) };
|
|
86
|
+
if (ref.hashAlg === 'blake3' && blake3Available !== true) return { ok: false, code: REF_V3_CODE.BLAKE3_UNAVAILABLE };
|
|
87
|
+
for (const name of ['chunkBytes', 'chunkCount', 'size']) {
|
|
88
|
+
if (!Number.isInteger(ref[name]) || ref[name] < (name === 'size' ? 0 : 1)) return { ok: false, code: REF_V3_CODE.BAD_SHAPE, detail: name };
|
|
89
|
+
}
|
|
90
|
+
if (ref.chunkCount !== Math.ceil(ref.size / ref.chunkBytes)) return { ok: false, code: REF_V3_CODE.INCONSISTENT, detail: 'chunkCount != ceil(size/chunkBytes)' };
|
|
91
|
+
if (ref.hashAlg === 'sha256' && hex64(ref.rootCid) !== hex64(ref.sha256Root)) return { ok: false, code: REF_V3_CODE.INCONSISTENT, detail: 'sha256 rootCid must equal sha256Root' };
|
|
92
|
+
return { ok: true, code: REF_V3_CODE.OK, ref, effectiveAlg: ref.hashAlg };
|
|
93
|
+
}
|
|
@@ -86,6 +86,9 @@ export function verifyRefV2(ref) {
|
|
|
86
86
|
if (typeof ref.ref !== 'string' || ref.ref === '') return { ok: false, code: 'ARTIFACT_REF_INVALID' };
|
|
87
87
|
if (typeof ref.rootCid !== 'string' || !ROOT.test(ref.rootCid)) return { ok: false, code: 'ARTIFACT_ROOT_INVALID' };
|
|
88
88
|
if (!Number.isSafeInteger(ref.size) || ref.size < 0 || !Number.isSafeInteger(ref.chunkCount) || ref.chunkCount < 0) return { ok: false, code: 'ARTIFACT_REF_INVALID' };
|
|
89
|
+
const plan = chunkLengths(ref.size, ref.chunkBytes);
|
|
90
|
+
if (plan.ok !== true) return plan;
|
|
91
|
+
if (plan.count !== ref.chunkCount) return { ok: false, code: 'ARTIFACT_REF_INCONSISTENT', detail: { expectedChunks: plan.count, chunkCount: ref.chunkCount } };
|
|
89
92
|
if (typeof ref.originPeer !== 'string' || ref.originPeer === '') return { ok: false, code: 'ARTIFACT_ORIGIN_REQUIRED' };
|
|
90
93
|
return { ok: true };
|
|
91
94
|
}
|
|
@@ -165,7 +168,7 @@ export function createReceiver({ stateDir, sinkRoot, ref, destination, chunkByte
|
|
|
165
168
|
if (buf.length !== range.length) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID', detail: { expected: range.length, got: buf.length } };
|
|
166
169
|
const digest = sha256(buf);
|
|
167
170
|
if (typeof expectedSha256 !== 'string' || expectedSha256 !== digest) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH', retry: index };
|
|
168
|
-
if (bitmap.hashes[index] !== null && bitmap.hashes[index] !== digest) return { ok: false, code: 'XFER_CHUNK_CONFLICT' };
|
|
171
|
+
if (bitmap.hashes[index] !== null && bitmap.hashes[index] !== digest) return { ok: false, code: 'XFER_CHUNK_CONFLICT', recoverable: true, retry: index };
|
|
169
172
|
if (bitmap.hashes[index] === digest) return { ok: true, duplicate: true, index };
|
|
170
173
|
const fd = openSync(partPath, 'r+');
|
|
171
174
|
try { writeSync(fd, buf, 0, buf.length, range.offset); } finally { closeSync(fd); }
|
|
@@ -187,6 +190,14 @@ export function createReceiver({ stateDir, sinkRoot, ref, destination, chunkByte
|
|
|
187
190
|
try { writeFileSync(join(dir, 'done.json'), JSON.stringify({ transferId, rootCid, size: bitmap.size, path: target, at: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 }); } catch { /* receipt is best effort */ }
|
|
188
191
|
return { ok: true, path: target, verified: true, bytes: bitmap.size, transferId };
|
|
189
192
|
},
|
|
193
|
+
/** Explicit recovery for a conflicting source: drop the claimed block so a correct source can win. */
|
|
194
|
+
evictChunk(index) {
|
|
195
|
+
if (!Number.isInteger(index) || index < 0 || index >= bitmap.chunkCount) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
|
|
196
|
+
if (bitmap.hashes[index] === null) return { ok: true, index, alreadyMissing: true };
|
|
197
|
+
bitmap.hashes[index] = null;
|
|
198
|
+
save();
|
|
199
|
+
return { ok: true, index, evicted: true };
|
|
200
|
+
},
|
|
190
201
|
/** 远端 P2P 拉取尚未接线:显式失败,不回落 relay。 */
|
|
191
202
|
async pullFrom(originPeer) {
|
|
192
203
|
return { ok: false, code: 'P2P_UNAVAILABLE', detail: { originPeer, why: 'want-block transport is a later slice; relay fallback is forbidden' } };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0-a: route `xfer.*` frames that arrive on a real P2PChannel.
|
|
3
|
+
*
|
|
4
|
+
* The router owns no transport: it is handed the frame and a channel, and it
|
|
5
|
+
* uses the existing chunk source/client primitives. Registering a source or a
|
|
6
|
+
* receiver is explicit, so an unregistered frame is dropped (never guessed).
|
|
7
|
+
* One listener per channel is the caller's job; this router never attaches one.
|
|
8
|
+
*/
|
|
9
|
+
import { createChunkSource, createWantClient } from './want-block.mjs';
|
|
10
|
+
|
|
11
|
+
export function createXferFrameRouter() {
|
|
12
|
+
const sources = new Map(); // rootCid -> { chunked, filePath, transferId, source }
|
|
13
|
+
const receivers = new Map(); // transferId -> { receiver, client }
|
|
14
|
+
|
|
15
|
+
const parse = (payload) => {
|
|
16
|
+
if (Buffer.isBuffer(payload) || typeof payload === 'string') {
|
|
17
|
+
try { return JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
return payload !== null && typeof payload === 'object' ? payload : null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
registerSource(rootCid, { chunked, filePath, transferId = null } = {}) {
|
|
24
|
+
if (typeof rootCid !== 'string' || rootCid === '') return { ok: false, code: 'XFER_ROOT_INVALID' };
|
|
25
|
+
sources.set(rootCid, { chunked, filePath, transferId, source: null, sourceTransferId: null });
|
|
26
|
+
return { ok: true };
|
|
27
|
+
},
|
|
28
|
+
registerReceiver(transferId, receiver, rootCid) {
|
|
29
|
+
if (typeof transferId !== 'string' || transferId === '') return { ok: false, code: 'XFER_TRANSFER_INVALID' };
|
|
30
|
+
const client = createWantClient({ transferId, rootCid, receiver });
|
|
31
|
+
if (client.ok !== true) return client;
|
|
32
|
+
receivers.set(transferId, { receiver, client });
|
|
33
|
+
return { ok: true };
|
|
34
|
+
},
|
|
35
|
+
unregister(rootCid) { sources.delete(rootCid); },
|
|
36
|
+
has(rootCid) { return sources.has(rootCid); },
|
|
37
|
+
/** Send the next want window for a registered receiver over a channel. */
|
|
38
|
+
request(transferId, channel) {
|
|
39
|
+
const entry = receivers.get(transferId);
|
|
40
|
+
if (entry === undefined || typeof channel?.send !== 'function') return { ok: false, code: 'XFER_RECEIVER_UNKNOWN' };
|
|
41
|
+
const missing = entry.client.missing();
|
|
42
|
+
if (missing.length === 0) return { ok: true, requested: 0 };
|
|
43
|
+
const frames = entry.client.requestFrames(missing);
|
|
44
|
+
for (const frame of frames) channel.send(JSON.stringify(frame));
|
|
45
|
+
return { ok: true, requested: missing.length };
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* @returns {boolean} true when the frame belonged to this layer (handled or refused)
|
|
49
|
+
*/
|
|
50
|
+
handle(payload, { channel } = {}) {
|
|
51
|
+
const frame = parse(payload);
|
|
52
|
+
if (frame === null || typeof frame.type !== 'string' || !frame.type.startsWith('xfer.')) return false;
|
|
53
|
+
if (typeof channel?.send !== 'function') return true;
|
|
54
|
+
if (frame.type === 'xfer.want') {
|
|
55
|
+
const entry = sources.get(frame.rootCid) ?? sources.get(frame.transferId);
|
|
56
|
+
if (entry === undefined) return true; // unknown source: drop, do not leak why
|
|
57
|
+
if (entry.source === null || entry.sourceTransferId !== frame.transferId) {
|
|
58
|
+
entry.source = createChunkSource({ chunked: entry.chunked, filePath: entry.filePath, transferId: frame.transferId });
|
|
59
|
+
entry.sourceTransferId = frame.transferId;
|
|
60
|
+
}
|
|
61
|
+
const served = entry.source.handle(frame);
|
|
62
|
+
if (served.ok !== true) { channel.send(JSON.stringify({ type: 'xfer.error', transferId: frame.transferId, code: served.code })); return true; }
|
|
63
|
+
for (const outgoing of served.frames) channel.send(JSON.stringify(outgoing));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (frame.type === 'xfer.chunk') {
|
|
67
|
+
const entry = receivers.get(frame.transferId);
|
|
68
|
+
if (entry === undefined) return true;
|
|
69
|
+
entry.receiver.writeChunk(frame.index, Buffer.from(String(frame.data), 'base64'), frame.sha256);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (frame.type === 'xfer.error') {
|
|
73
|
+
const entry = receivers.get(frame.transferId);
|
|
74
|
+
if (entry !== undefined) entry.lastError = frame.code ?? 'XFER_PEER_ERROR';
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -87,3 +87,49 @@ export function verifyChunkFrame(frame) {
|
|
|
87
87
|
if (digest !== frame.sha256) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
|
|
88
88
|
return { ok: true, data };
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* P0-a: run the chunk source over a MessageChannel (P2PChannel / iroh sidecar)
|
|
94
|
+
* instead of a bare dgram socket. Frames stay JSON for wire compatibility; the
|
|
95
|
+
* channel supplies encryption, ACK and fragmentation.
|
|
96
|
+
*/
|
|
97
|
+
export function attachChunkSource(channel, { chunked, filePath, transferId = null }) {
|
|
98
|
+
const source = createChunkSource({ chunked, filePath, transferId });
|
|
99
|
+
if (source.ok !== true) return source;
|
|
100
|
+
const handler = (payload) => {
|
|
101
|
+
let frame = null;
|
|
102
|
+
try { frame = JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return; }
|
|
103
|
+
const served = source.handle(frame);
|
|
104
|
+
if (served.ok !== true) { channel.send(JSON.stringify({ type: ERROR, transferId: frame?.transferId ?? null, code: served.code })); return; }
|
|
105
|
+
for (const outgoing of served.frames) channel.send(JSON.stringify(outgoing));
|
|
106
|
+
};
|
|
107
|
+
channel.on('message', handler);
|
|
108
|
+
return { ok: true, close: () => channel.off?.('message', handler) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* P0-a: receiver side over a MessageChannel. request() sends the next window;
|
|
113
|
+
* inbound chunk frames are validated and written exactly as in the UDP path.
|
|
114
|
+
*/
|
|
115
|
+
export function attachWantClient(channel, { transferId, rootCid, receiver }) {
|
|
116
|
+
const client = createWantClient({ transferId, rootCid, receiver });
|
|
117
|
+
if (client.ok !== true) return client;
|
|
118
|
+
const handler = (payload) => {
|
|
119
|
+
let frame = null;
|
|
120
|
+
try { frame = JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return; }
|
|
121
|
+
client.accept(frame);
|
|
122
|
+
};
|
|
123
|
+
channel.on('message', handler);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
client,
|
|
127
|
+
request() {
|
|
128
|
+
const missing = client.missing();
|
|
129
|
+
if (missing.length === 0) return 0;
|
|
130
|
+
channel.send(JSON.stringify(client.requestFrames(missing)[0]));
|
|
131
|
+
return missing.length;
|
|
132
|
+
},
|
|
133
|
+
close: () => channel.off?.('message', handler),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
294
|
+
const PLUGIN_VERSION = '2.0.10';
|
|
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
|
-
|
|
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
|
}
|