@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
@@ -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
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 中国象棋核心规则(联合开发演示用,零依赖)。
3
+ *
4
+ * 棋盘 10 行 × 9 列:row 0 为黑方底线,row 9 为红方底线;红先。
5
+ * 棋子:K 帅/将, A 仕/士, E 相/象, H 马, R 车, C 炮, P 兵/卒。
6
+ * 已实现:全部走子规则、炮架、马腿、象眼、过河兵、九宫、飞将、将军/将死/困毙(困毙判负)。
7
+ */
8
+ export const COLS = 9;
9
+ export const ROWS = 10;
10
+ const RED = 'r';
11
+ const BLACK = 'b';
12
+
13
+ const START = [
14
+ ['r', 'h', 'e', 'a', 'k', 'a', 'e', 'h', 'r'],
15
+ [null, null, null, null, null, null, null, null, null],
16
+ [null, 'c', null, null, null, null, null, 'c', null],
17
+ ['p', null, 'p', null, 'p', null, 'p', null, 'p'],
18
+ [null, null, null, null, null, null, null, null, null],
19
+ [null, null, null, null, null, null, null, null, null],
20
+ ['P', null, 'P', null, 'P', null, 'P', null, 'P'],
21
+ [null, 'C', null, null, null, null, null, 'C', null],
22
+ [null, null, null, null, null, null, null, null, null],
23
+ ['R', 'H', 'E', 'A', 'K', 'A', 'E', 'H', 'R'],
24
+ ];
25
+
26
+ const isRed = (piece) => piece !== null && piece === piece.toUpperCase();
27
+ const sideOf = (piece) => (piece === null ? null : isRed(piece) ? RED : BLACK);
28
+
29
+ export function createGame() {
30
+ return {
31
+ board: START.map((row) => row.slice()),
32
+ turn: RED,
33
+ ply: 0,
34
+ history: [],
35
+ result: null,
36
+ };
37
+ }
38
+
39
+ const cloneBoard = (board) => board.map((row) => row.slice());
40
+ const inBoard = (r, c) => r >= 0 && r < ROWS && c >= 0 && c < COLS;
41
+ const inPalace = (side, r, c) => {
42
+ if (c < 3 || c > 5) return false;
43
+ return side === RED ? r >= 7 && r <= 9 : r >= 0 && r <= 2;
44
+ };
45
+ const crossedRiver = (side, r) => (side === RED ? r <= 4 : r >= 5);
46
+
47
+ function pseudoMoves(board, side, r, c) {
48
+ const piece = board[r][c];
49
+ if (piece === null || sideOf(piece) !== side) return [];
50
+ const type = piece.toUpperCase();
51
+ const out = [];
52
+ const push = (rr, cc) => {
53
+ if (!inBoard(rr, cc)) return;
54
+ const target = board[rr][cc];
55
+ if (target === null || sideOf(target) !== side) out.push([rr, cc]);
56
+ };
57
+ if (type === 'K') {
58
+ for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
59
+ const rr = r + dr; const cc = c + dc;
60
+ if (inPalace(side, rr, cc)) push(rr, cc);
61
+ }
62
+ } else if (type === 'A') {
63
+ for (const [dr, dc] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) {
64
+ const rr = r + dr; const cc = c + dc;
65
+ if (inPalace(side, rr, cc)) push(rr, cc);
66
+ }
67
+ } else if (type === 'E') {
68
+ for (const [dr, dc] of [[2, 2], [2, -2], [-2, 2], [-2, -2]]) {
69
+ const rr = r + dr; const cc = c + dc;
70
+ if (!inBoard(rr, cc) || crossedRiver(side, rr)) continue;
71
+ if (board[r + dr / 2][c + dc / 2] !== null) continue;
72
+ push(rr, cc);
73
+ }
74
+ } else if (type === 'H') {
75
+ for (const [dr, dc, lr, lc] of [
76
+ [2, 1, 1, 0], [2, -1, 1, 0], [-2, 1, -1, 0], [-2, -1, -1, 0],
77
+ [1, 2, 0, 1], [-1, 2, 0, 1], [1, -2, 0, -1], [-1, -2, 0, -1],
78
+ ]) {
79
+ const rr = r + dr; const cc = c + dc;
80
+ if (!inBoard(rr, cc)) continue;
81
+ if (board[r + lr][c + lc] !== null) continue;
82
+ push(rr, cc);
83
+ }
84
+ } else if (type === 'R' || type === 'C') {
85
+ for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
86
+ let rr = r + dr; let cc = c + dc; let screen = false;
87
+ while (inBoard(rr, cc)) {
88
+ const target = board[rr][cc];
89
+ if (type === 'R') {
90
+ if (target === null) out.push([rr, cc]);
91
+ else { if (sideOf(target) !== side) out.push([rr, cc]); break; }
92
+ } else if (target === null) {
93
+ if (!screen) out.push([rr, cc]);
94
+ } else if (!screen) {
95
+ screen = true;
96
+ } else {
97
+ if (sideOf(target) !== side) out.push([rr, cc]);
98
+ break;
99
+ }
100
+ rr += dr; cc += dc;
101
+ }
102
+ }
103
+ } else if (type === 'P') {
104
+ const forward = side === RED ? -1 : 1;
105
+ push(r + forward, c);
106
+ if (crossedRiver(side, r)) { push(r, c - 1); push(r, c + 1); }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function kingAt(board, side) {
112
+ for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
113
+ const p = board[r][c];
114
+ if (p !== null && p.toUpperCase() === 'K' && sideOf(p) === side) return [r, c];
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function kingsFace(board) {
120
+ const rk = kingAt(board, RED); const bk = kingAt(board, BLACK);
121
+ if (rk === null || bk === null || rk[1] !== bk[1]) return false;
122
+ const c = rk[1];
123
+ const from = Math.min(rk[0], bk[0]) + 1; const to = Math.max(rk[0], bk[0]);
124
+ for (let r = from; r < to; r += 1) if (board[r][c] !== null) return false;
125
+ return true;
126
+ }
127
+
128
+ /** 某方是否被将军(含飞将)。 */
129
+ export function inCheck(board, side) {
130
+ if (kingsFace(board)) return true;
131
+ const king = kingAt(board, side);
132
+ if (king === null) return true;
133
+ const enemy = side === RED ? BLACK : RED;
134
+ for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
135
+ const p = board[r][c];
136
+ if (p === null || sideOf(p) !== enemy) continue;
137
+ if (pseudoMoves(board, enemy, r, c).some(([rr, cc]) => rr === king[0] && cc === king[1])) return true;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ export function legalMovesFrom(board, side, r, c) {
143
+ return pseudoMoves(board, side, r, c).filter(([rr, cc]) => {
144
+ const next = cloneBoard(board);
145
+ next[rr][cc] = next[r][c]; next[r][c] = null;
146
+ return !inCheck(next, side);
147
+ });
148
+ }
149
+
150
+ export function allLegalMoves(board, side) {
151
+ const out = [];
152
+ for (let r = 0; r < ROWS; r += 1) for (let c = 0; c < COLS; c += 1) {
153
+ if (board[r][c] !== null && sideOf(board[r][c]) === side) {
154
+ for (const [rr, cc] of legalMovesFrom(board, side, r, c)) out.push({ from: [r, c], to: [rr, cc] });
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ const FILE = 'abcdefghi';
161
+ export const squareName = (r, c) => FILE[c] + String(ROWS - r);
162
+
163
+ /** @returns {{ok: true, state: object, move: object} | {ok: false, code: string}} */
164
+ export function applyMove(game, from, to) {
165
+ if (game.result !== null) return { ok: false, code: 'GAME_OVER' };
166
+ if (!Array.isArray(from) || !Array.isArray(to) || !inBoard(from[0], from[1]) || !inBoard(to[0], to[1])) return { ok: false, code: 'SQUARE_INVALID' };
167
+ const piece = game.board[from[0]][from[1]];
168
+ if (piece === null || sideOf(piece) !== game.turn) return { ok: false, code: 'NOT_YOUR_PIECE' };
169
+ const legal = legalMovesFrom(game.board, game.turn, from[0], from[1]);
170
+ if (!legal.some(([r, c]) => r === to[0] && c === to[1])) return { ok: false, code: 'ILLEGAL_MOVE' };
171
+ const board = cloneBoard(game.board);
172
+ const captured = board[to[0]][to[1]];
173
+ board[to[0]][to[1]] = piece; board[from[0]][from[1]] = null;
174
+ const mover = game.turn;
175
+ const next = { ...game, board, turn: mover === RED ? BLACK : RED, ply: game.ply + 1, history: [...game.history, { from, to, piece, captured }] };
176
+ const opponentHasMoves = allLegalMoves(board, next.turn).length > 0;
177
+ const opponentChecked = inCheck(board, next.turn);
178
+ if (!opponentHasMoves) {
179
+ next.result = { winner: mover, reason: captured !== null && captured.toUpperCase() === 'K' ? 'KING_CAPTURED' : opponentChecked ? 'CHECKMATE' : 'STALEMATE' };
180
+ } else if (opponentChecked) {
181
+ next.check = true;
182
+ } else {
183
+ next.check = false;
184
+ }
185
+ next.lastMove = { from, to, piece, captured, check: opponentChecked === true && next.result === null };
186
+ return { ok: true, state: next, move: next.lastMove };
187
+ }
188
+
189
+ const GLYPH = { K: '帅', A: '仕', E: '相', H: '马', R: '车', C: '炮', P: '兵', k: '将', a: '士', e: '象', h: '马', r: '车', c: '炮', p: '卒' };
190
+
191
+ export function boardAscii(game) {
192
+ const lines = [' a b c d e f g h i'];
193
+ for (let r = 0; r < ROWS; r += 1) {
194
+ const cells = [];
195
+ for (let c = 0; c < COLS; c += 1) {
196
+ const p = game.board[r][c];
197
+ cells.push(p === null ? '·' : GLYPH[p] ?? p);
198
+ }
199
+ lines.push(String(ROWS - r).padStart(2, ' ') + ' ' + cells.join(' '));
200
+ }
201
+ lines.push(' a b c d e f g h i');
202
+ return lines.join('\n');
203
+ }
204
+
205
+ export function moveText(move) {
206
+ return squareName(move.from[0], move.from[1]) + '-' + squareName(move.to[0], move.to[1]);
207
+ }
208
+
209
+ export function describeProgress(game) {
210
+ const side = game.turn === RED ? '红' : '黑';
211
+ if (game.result !== null) {
212
+ const winner = game.result.winner === RED ? '红方' : '黑方';
213
+ return `第 ${game.ply} 手结束:${winner}胜(${game.result.reason})`;
214
+ }
215
+ const last = game.lastMove ? `上一手 ${moveText(game.lastMove)}${game.lastMove.captured ? ' 吃 ' + GLYPH[game.lastMove.captured] : ''}` : '开局';
216
+ return `第 ${game.ply} 手 · ${side}方行棋 · ${last}${game.check ? ' · 将军!' : ''}`;
217
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Fleet binding diagnostics (read-only).
3
+ *
4
+ * A device token can be valid while `fleet_id` is null: the relay lists the machine globally
5
+ * ("all machines") but filters it out of every fleet view ("this fleet"). v0.8.7's
6
+ * "already paired -> do not pair again" rule made that state sticky, so it needs to be
7
+ * *observed* rather than inferred from "paired: true".
8
+ *
9
+ * Nothing here writes: the status tool reports, and the auto-start path decides whether to
10
+ * re-admit using the fleet key it already holds.
11
+ */
12
+
13
+ export const FLEET_BINDING_CODE = Object.freeze({
14
+ BOUND: 'FLEET_BOUND',
15
+ UNBOUND: 'FLEET_UNBOUND',
16
+ UNPAIRED: 'FLEET_UNPAIRED',
17
+ TOKEN_REJECTED: 'FLEET_TOKEN_REJECTED',
18
+ UNKNOWN: 'FLEET_UNKNOWN',
19
+ });
20
+
21
+ /**
22
+ * Should a start-up re-admit even though a token exists?
23
+ * Only when the token is valid, the machine has no fleet, and a key is available to bind it.
24
+ */
25
+ export function shouldRejoin({ token, fleetId, fleetKey }) {
26
+ return typeof token === 'string' && token !== ''
27
+ && (fleetId === null || fleetId === undefined || fleetId === '')
28
+ && typeof fleetKey === 'string' && fleetKey.trim() !== '';
29
+ }
30
+
31
+ /**
32
+ * Read this machine's relay record and classify its fleet binding.
33
+ *
34
+ * @param {{relayUrl: string, token: string|null, machineId: string|null,
35
+ * fleetKey: string|null, fetchImpl?: Function, timeoutMs?: number}} input
36
+ * @returns {Promise<object>} a bounded, token-free status block
37
+ */
38
+ export async function diagnoseFleetBinding({ relayUrl, token, machineId, fleetKey = null, fetchImpl = fetch, timeoutMs = 2500 }) {
39
+ const checkedAt = new Date().toISOString();
40
+ const fleetKeyConfigured = typeof fleetKey === 'string' && fleetKey.trim() !== '';
41
+ const base = {
42
+ ok: true,
43
+ state: 'unknown',
44
+ code: FLEET_BINDING_CODE.UNKNOWN,
45
+ fleet_id: null,
46
+ token_present: typeof token === 'string' && token !== '',
47
+ fleet_key_configured: fleetKeyConfigured,
48
+ machine_id: machineId,
49
+ checked_at: checkedAt,
50
+ detail: null,
51
+ remedy: null,
52
+ };
53
+ if (base.token_present !== true) {
54
+ return { ...base, state: 'unpaired', code: FLEET_BINDING_CODE.UNPAIRED, detail: 'this machine has no device token' };
55
+ }
56
+ if (typeof relayUrl !== 'string' || relayUrl === '') {
57
+ return { ...base, detail: 'no relay URL is configured' };
58
+ }
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
61
+ try {
62
+ const response = await fetchImpl(relayUrl.replace(/\/+$/, '') + '/v1/devices', {
63
+ headers: { authorization: 'Bearer ' + token, accept: 'application/json' },
64
+ signal: controller.signal,
65
+ });
66
+ if (response.status === 401 || response.status === 403) {
67
+ return {
68
+ ...base,
69
+ state: 'token_rejected',
70
+ code: FLEET_BINDING_CODE.TOKEN_REJECTED,
71
+ detail: 'the relay rejected this device token (HTTP ' + response.status + ')',
72
+ remedy: 're-pair this machine (the stored token is stale for this relay)',
73
+ };
74
+ }
75
+ if (response.ok !== true) {
76
+ return { ...base, detail: 'the relay answered HTTP ' + response.status };
77
+ }
78
+ const body = await response.json().catch(() => null);
79
+ const devices = Array.isArray(body?.devices) ? body.devices : [];
80
+ const self = devices.find((entry) => entry?.machine_id === machineId) ?? null;
81
+ if (self === null) {
82
+ return { ...base, detail: 'the relay answered 200 but does not list this machine' };
83
+ }
84
+ const fleetId = typeof self.fleet_id === 'string' && self.fleet_id !== '' ? self.fleet_id : null;
85
+ if (fleetId === null) {
86
+ return {
87
+ ...base,
88
+ state: 'unbound',
89
+ code: FLEET_BINDING_CODE.UNBOUND,
90
+ detail: 'the token is valid, but the relay records this machine with fleet_id=null',
91
+ remedy: fleetKeyConfigured
92
+ ? 'a fleet key is configured; the next start re-admits this machine into its fleet'
93
+ : 'set fleetKey / W2M_FLEET_KEY, then restart the agent so the machine can join its fleet',
94
+ };
95
+ }
96
+ return { ...base, state: 'bound', code: FLEET_BINDING_CODE.BOUND, fleet_id: fleetId };
97
+ } catch (error) {
98
+ return {
99
+ ...base,
100
+ detail: 'the fleet binding could not be read: ' + (error instanceof Error ? error.message : String(error)),
101
+ };
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
@@ -1,4 +1,5 @@
1
1
  import { FAILOVER_DEFAULTS, createRelayFailover, relayPool } from './relay-failover.mjs';
2
+ import { diagnoseFleetBinding, shouldRejoin } from './fleet-binding.mjs';
2
3
  import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
3
4
  /**
4
5
  * DSH: Windows2MacO - the DSH Cordis plugin.
@@ -290,7 +291,7 @@ const MAX_STAGES = 16;
290
291
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
291
292
  * overrides it at runtime for tests and packagers.
292
293
  */
293
- const PLUGIN_VERSION = '2.0.7';
294
+ const PLUGIN_VERSION = '2.0.9';
294
295
 
295
296
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
296
297
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -1680,7 +1681,17 @@ async function pushOffer(node, frame, crypto = {}) {
1680
1681
  // no channel at all.
1681
1682
  const dialOptions = session === undefined ? {} : { session };
1682
1683
  try {
1683
- 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
+ }
1684
1695
  if (!dialled.ok) {
1685
1696
  return {
1686
1697
  ok: false,
@@ -4997,6 +5008,13 @@ const result = await request({
4997
5008
  };
4998
5009
  })();
4999
5010
 
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
+
5000
5018
  return JSON.stringify(
5001
5019
  {
5002
5020
  ok: true,
@@ -5021,6 +5039,7 @@ const result = await request({
5021
5039
  rabbit_source: cfg.rabbitSource,
5022
5040
  rabbit_url: cfg.rabbitUrl,
5023
5041
  p2p: p2pStatus,
5042
+ fleet: fleetBinding,
5024
5043
  rtt,
5025
5044
  config: {
5026
5045
  rabbitUrl: cfg.rabbitUrl,
@@ -5031,6 +5050,7 @@ const result = await request({
5031
5050
  autoStartAgent: cfg.autoStartAgent,
5032
5051
  allowedCommands: cfg.allowedCommands,
5033
5052
  pairingCode_configured: cfg.pairingCode !== null,
5053
+ fleetKey_configured: cfg.fleetKey !== null,
5034
5054
  p2pMode: cfg.p2pMode,
5035
5055
  // The resolved list, shared server first, exactly as the node will query it.
5036
5056
  stunServers: cfg.stunServers,
@@ -8029,6 +8049,18 @@ async function startLocalStack(ctx, cfg) {
8029
8049
  if (identityOptions.identity !== undefined && identityOptions.identity !== null) identityOptions.identity.device_token = null;
8030
8050
  ctx.logger?.warn?.('w2m: stored device token was rejected by ' + relayForToken + ' (HTTP ' + probe.status + '); pairing again with the fleet key');
8031
8051
  }
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
+ }
8032
8064
  } catch { /* network error: keep the token, do not force a re-pair */ }
8033
8065
  }
8034
8066
  }