clawmatrix 0.1.22 → 0.2.0
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/README.md +4 -1
- package/package.json +4 -2
- package/src/acp-proxy.ts +2073 -0
- package/src/audit.ts +42 -0
- package/src/auth.ts +2 -3
- package/src/cli.ts +76 -2
- package/src/cluster-service.ts +243 -3
- package/src/compat.ts +84 -3
- package/src/config.ts +117 -4
- package/src/connection.ts +290 -85
- package/src/crypto.ts +179 -0
- package/src/debug.ts +15 -2
- package/src/e2e/helpers.ts +318 -0
- package/src/handoff.ts +132 -87
- package/src/identity.ts +95 -0
- package/src/index.ts +539 -45
- package/src/knowledge-sync.ts +777 -205
- package/src/local-tools.ts +9 -2
- package/src/model-proxy.ts +358 -110
- package/src/peer-approval.ts +628 -0
- package/src/peer-manager.ts +270 -38
- package/src/rate-limiter.ts +88 -0
- package/src/router.ts +32 -10
- package/src/sentinel-manager.ts +142 -0
- package/src/sentinel.ts +618 -0
- package/src/task-activity.ts +74 -0
- package/src/terminal.ts +566 -0
- package/src/tool-proxy.ts +127 -3
- package/src/tools/cluster-acp.ts +237 -0
- package/src/tools/cluster-batch.ts +76 -0
- package/src/tools/cluster-diagnostic.ts +174 -0
- package/src/tools/cluster-edit.ts +70 -0
- package/src/tools/cluster-peers.ts +59 -14
- package/src/tools/cluster-terminal.ts +232 -0
- package/src/tools/cluster-tool.ts +26 -11
- package/src/types.ts +477 -3
- package/src/web.ts +2 -2
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE cryptographic primitives: X25519 ECDH key exchange + AES-256-GCM payload encryption.
|
|
3
|
+
*
|
|
4
|
+
* Uses node:crypto for X25519 (not available in Web Crypto API) and
|
|
5
|
+
* synchronous AES-256-GCM to keep Connection.send() non-async.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
generateKeyPairSync,
|
|
10
|
+
diffieHellman,
|
|
11
|
+
createPublicKey,
|
|
12
|
+
createPrivateKey,
|
|
13
|
+
createCipheriv,
|
|
14
|
+
createDecipheriv,
|
|
15
|
+
hkdfSync,
|
|
16
|
+
randomBytes,
|
|
17
|
+
} from "node:crypto";
|
|
18
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
19
|
+
|
|
20
|
+
// ── Key exchange ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface KeyPair {
|
|
23
|
+
publicKey: Buffer; // 32 bytes raw X25519 public key
|
|
24
|
+
privateKey: Buffer; // 32 bytes raw X25519 private key (PKCS8-wrapped internally)
|
|
25
|
+
/** PKCS8-encoded private key for node:crypto diffieHellman. */
|
|
26
|
+
_privateKeyObject: ReturnType<typeof createPrivateKey>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Generate an ephemeral X25519 keypair. */
|
|
30
|
+
export function generateX25519KeyPair(): KeyPair {
|
|
31
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519", {
|
|
32
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
33
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Extract raw 32-byte public key from SPKI DER (last 32 bytes)
|
|
37
|
+
const rawPublic = Buffer.from(publicKey.subarray(publicKey.length - 32));
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
publicKey: rawPublic,
|
|
41
|
+
privateKey: Buffer.from(privateKey),
|
|
42
|
+
_privateKeyObject: createPrivateKey({
|
|
43
|
+
key: privateKey,
|
|
44
|
+
format: "der",
|
|
45
|
+
type: "pkcs8",
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Derive a 256-bit AES session key from X25519 ECDH shared secret.
|
|
51
|
+
* Both sides produce the same key regardless of who is client/server. */
|
|
52
|
+
export function deriveSessionKey(
|
|
53
|
+
localKeyPair: KeyPair,
|
|
54
|
+
remotePublicKeyRaw: Buffer,
|
|
55
|
+
salt?: Buffer,
|
|
56
|
+
): Buffer {
|
|
57
|
+
// Wrap raw 32-byte public key back into SPKI DER for node:crypto
|
|
58
|
+
const spkiPrefix = Buffer.from([
|
|
59
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
|
60
|
+
0x6e, 0x03, 0x21, 0x00,
|
|
61
|
+
]);
|
|
62
|
+
const spkiDer = Buffer.concat([spkiPrefix, remotePublicKeyRaw]);
|
|
63
|
+
const remoteKeyObject = createPublicKey({
|
|
64
|
+
key: spkiDer,
|
|
65
|
+
format: "der",
|
|
66
|
+
type: "spki",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const sharedSecret = diffieHellman({
|
|
70
|
+
privateKey: localKeyPair._privateKeyObject,
|
|
71
|
+
publicKey: remoteKeyObject,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// HKDF-SHA256: derive 32-byte AES key with fixed info string
|
|
75
|
+
const derived = hkdfSync(
|
|
76
|
+
"sha256",
|
|
77
|
+
sharedSecret,
|
|
78
|
+
salt ?? Buffer.alloc(0), // salt should include connection-specific nonce for forward secrecy
|
|
79
|
+
"clawmatrix-e2ee-v1",
|
|
80
|
+
32,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return Buffer.from(derived);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Binary encrypted wire format ────────────────────────────────────
|
|
87
|
+
// Wire: base64([flags:1][iv:12][ciphertext][tag:16])
|
|
88
|
+
// flags: bit 0 = compressed, bits 1-7 reserved
|
|
89
|
+
// The result is a single base64 string — no JSON structure to fingerprint.
|
|
90
|
+
|
|
91
|
+
/** Encrypt a plaintext string to a base64 binary envelope.
|
|
92
|
+
* Applies random padding (16-64 bytes) to resist traffic analysis. */
|
|
93
|
+
export function encryptBinary(
|
|
94
|
+
sessionKey: Buffer,
|
|
95
|
+
plaintext: string,
|
|
96
|
+
compress: boolean = false,
|
|
97
|
+
): string {
|
|
98
|
+
let data: Buffer;
|
|
99
|
+
let flags = 0;
|
|
100
|
+
|
|
101
|
+
if (compress && plaintext.length > 256) {
|
|
102
|
+
const deflated = deflateSync(Buffer.from(plaintext));
|
|
103
|
+
if (deflated.length < plaintext.length * 0.9) {
|
|
104
|
+
data = deflated;
|
|
105
|
+
flags |= 0x01;
|
|
106
|
+
} else {
|
|
107
|
+
data = Buffer.from(plaintext);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
data = Buffer.from(plaintext);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Random padding: 4-byte length prefix + data + random padding
|
|
114
|
+
const padLen = 16 + (randomBytes(1)[0]! % 48);
|
|
115
|
+
const lenBuf = Buffer.alloc(4);
|
|
116
|
+
lenBuf.writeUInt32BE(data.length, 0);
|
|
117
|
+
const padded = Buffer.concat([lenBuf, data, randomBytes(padLen)]);
|
|
118
|
+
|
|
119
|
+
const iv = randomBytes(12);
|
|
120
|
+
const cipher = createCipheriv("aes-256-gcm", sessionKey, iv);
|
|
121
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
122
|
+
const tag = cipher.getAuthTag();
|
|
123
|
+
|
|
124
|
+
return Buffer.concat([Buffer.from([flags]), iv, encrypted, tag]).toString("base64");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Decrypt a base64 binary envelope. Throws on auth failure. */
|
|
128
|
+
export function decryptBinary(
|
|
129
|
+
sessionKey: Buffer,
|
|
130
|
+
b64: string,
|
|
131
|
+
): string {
|
|
132
|
+
const buf = Buffer.from(b64, "base64");
|
|
133
|
+
if (buf.length < 1 + 12 + 16) throw new Error("Encrypted data too short");
|
|
134
|
+
|
|
135
|
+
const flags = buf[0]!;
|
|
136
|
+
const iv = buf.subarray(1, 13);
|
|
137
|
+
const rest = buf.subarray(13);
|
|
138
|
+
const ciphertext = rest.subarray(0, rest.length - 16);
|
|
139
|
+
const tag = rest.subarray(rest.length - 16);
|
|
140
|
+
|
|
141
|
+
const decipher = createDecipheriv("aes-256-gcm", sessionKey, iv);
|
|
142
|
+
decipher.setAuthTag(tag);
|
|
143
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
144
|
+
|
|
145
|
+
// Strip length-prefixed padding
|
|
146
|
+
const actualLen = decrypted.readUInt32BE(0);
|
|
147
|
+
const data = decrypted.subarray(4, 4 + actualLen);
|
|
148
|
+
|
|
149
|
+
if (flags & 0x01) {
|
|
150
|
+
return inflateSync(data).toString();
|
|
151
|
+
}
|
|
152
|
+
return data.toString();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Check if a raw WS message string is a binary encrypted envelope (not JSON). */
|
|
156
|
+
export function isBinaryEncrypted(s: string): boolean {
|
|
157
|
+
return s.length > 0 && s[0] !== "{" && s[0] !== "[";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/** Encode a raw public key to base64 for wire transmission. */
|
|
163
|
+
export function publicKeyToBase64(key: Buffer): string {
|
|
164
|
+
return key.toString("base64");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Decode a base64 public key from wire format. */
|
|
168
|
+
export function base64ToPublicKey(b64: string): Buffer {
|
|
169
|
+
return Buffer.from(b64, "base64");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Derive a per-peer authentication secret from the master secret and both node IDs.
|
|
173
|
+
* Sorted node IDs ensure both sides derive the same value regardless of who initiates. */
|
|
174
|
+
export function derivePerPeerSecret(masterSecret: string, nodeIdA: string, nodeIdB: string): string {
|
|
175
|
+
const sorted = [nodeIdA, nodeIdB].sort();
|
|
176
|
+
const salt = Buffer.from(sorted.join(":"));
|
|
177
|
+
const derived = hkdfSync("sha256", Buffer.from(masterSecret), salt, "clawmatrix-peer-auth-v1", 32);
|
|
178
|
+
return Buffer.from(derived).toString("hex");
|
|
179
|
+
}
|
package/src/debug.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
type Logger = {
|
|
2
|
+
info: (message: string) => void;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
let _logger: Logger | undefined;
|
|
6
|
+
|
|
7
|
+
export function initDebugLogger(logger: Logger) {
|
|
8
|
+
_logger = logger;
|
|
9
|
+
}
|
|
2
10
|
|
|
3
11
|
export function debug(tag: string, msg: string) {
|
|
4
|
-
|
|
12
|
+
const message = `[clawmatrix:${tag}] ${msg}`;
|
|
13
|
+
if (_logger) {
|
|
14
|
+
_logger.info(message);
|
|
15
|
+
} else {
|
|
16
|
+
console.log(message);
|
|
17
|
+
}
|
|
5
18
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test infrastructure for E2E tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides TestNode (wraps PeerManager + HandoffManager + ModelProxy + ToolProxy
|
|
5
|
+
* with real WebSocket connections) and helpers for port allocation, mesh readiness,
|
|
6
|
+
* and mock HTTP servers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { PeerManager } from "../peer-manager.ts";
|
|
10
|
+
import { HandoffManager } from "../handoff.ts";
|
|
11
|
+
import { ModelProxy } from "../model-proxy.ts";
|
|
12
|
+
import { ToolProxy, type GatewayInfo } from "../tool-proxy.ts";
|
|
13
|
+
import type { ClawMatrixConfig } from "../config.ts";
|
|
14
|
+
import type { AnyClusterFrame } from "../types.ts";
|
|
15
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
16
|
+
|
|
17
|
+
// ── Port allocation ─────────────────────────────────────────────────
|
|
18
|
+
// Range 19500-19999, avoiding mesh.test.ts range (19100-19400).
|
|
19
|
+
let nextPort = 19500;
|
|
20
|
+
|
|
21
|
+
/** Allocate a unique port for a test node. Not concurrency-safe across processes. */
|
|
22
|
+
export function allocPort(): number {
|
|
23
|
+
return nextPort++;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reset port counter (call in afterAll if tests run in a loop). */
|
|
27
|
+
export function resetPorts(start = 19500): void {
|
|
28
|
+
nextPort = start;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Logger ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Minimal no-op logger satisfying PluginLogger. */
|
|
34
|
+
export function createTestLogger(): PluginLogger {
|
|
35
|
+
return {
|
|
36
|
+
info: () => {},
|
|
37
|
+
warn: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
debug: () => {},
|
|
40
|
+
} as unknown as PluginLogger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Config builder ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface TestNodeOptions {
|
|
46
|
+
nodeId: string;
|
|
47
|
+
listen?: boolean;
|
|
48
|
+
listenPort?: number;
|
|
49
|
+
peers?: Array<{ nodeId: string; url: string }>;
|
|
50
|
+
agents?: Array<{ id: string; description: string; tags: string[] }>;
|
|
51
|
+
models?: Array<{
|
|
52
|
+
id: string;
|
|
53
|
+
provider: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
apiKey?: string;
|
|
57
|
+
api?: string;
|
|
58
|
+
}>;
|
|
59
|
+
tags?: string[];
|
|
60
|
+
proxyPort?: number;
|
|
61
|
+
toolProxy?: {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
allow?: string[];
|
|
64
|
+
deny?: string[];
|
|
65
|
+
maxOutputBytes?: number;
|
|
66
|
+
};
|
|
67
|
+
proxyModels?: Array<{
|
|
68
|
+
id: string;
|
|
69
|
+
nodeId: string;
|
|
70
|
+
provider?: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
api?: string;
|
|
73
|
+
}>;
|
|
74
|
+
handoffTimeout?: number;
|
|
75
|
+
toolTimeout?: number;
|
|
76
|
+
modelTimeout?: number;
|
|
77
|
+
/** Override gatewayInfo (default: port 0, no auth). */
|
|
78
|
+
gatewayInfo?: GatewayInfo;
|
|
79
|
+
/** Enable E2E encryption (default: false). */
|
|
80
|
+
e2ee?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildConfig(options: TestNodeOptions): ClawMatrixConfig {
|
|
84
|
+
return {
|
|
85
|
+
nodeId: options.nodeId,
|
|
86
|
+
secret: "test-secret-long-enough",
|
|
87
|
+
listen: options.listen ?? false,
|
|
88
|
+
listenHost: "0.0.0.0",
|
|
89
|
+
listenPort: options.listenPort ?? 0,
|
|
90
|
+
peers: options.peers ?? [],
|
|
91
|
+
agents: options.agents ?? [],
|
|
92
|
+
models: options.models ?? [],
|
|
93
|
+
proxyModels: (options.proxyModels ?? []).map((m) => ({
|
|
94
|
+
id: m.id,
|
|
95
|
+
nodeId: m.nodeId,
|
|
96
|
+
provider: m.provider,
|
|
97
|
+
description: m.description,
|
|
98
|
+
api: m.api,
|
|
99
|
+
})),
|
|
100
|
+
tags: options.tags ?? [],
|
|
101
|
+
proxyPort: options.proxyPort ?? 0,
|
|
102
|
+
toolProxy: options.toolProxy
|
|
103
|
+
? {
|
|
104
|
+
enabled: options.toolProxy.enabled,
|
|
105
|
+
allow: options.toolProxy.allow ?? [],
|
|
106
|
+
deny: options.toolProxy.deny ?? [],
|
|
107
|
+
maxOutputBytes: options.toolProxy.maxOutputBytes ?? 1_048_576,
|
|
108
|
+
}
|
|
109
|
+
: undefined,
|
|
110
|
+
handoffTimeout: options.handoffTimeout ?? 600_000,
|
|
111
|
+
modelTimeout: options.modelTimeout ?? 120_000,
|
|
112
|
+
toolTimeout: options.toolTimeout ?? 30_000,
|
|
113
|
+
peerApproval: {
|
|
114
|
+
enabled: false,
|
|
115
|
+
mode: "notify",
|
|
116
|
+
timeout: 1_200_000,
|
|
117
|
+
allowList: [],
|
|
118
|
+
persistPath: "approved-peers.json",
|
|
119
|
+
},
|
|
120
|
+
e2ee: options.e2ee ?? false,
|
|
121
|
+
compression: false,
|
|
122
|
+
} as ClawMatrixConfig;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── TestNode ────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export class TestNode {
|
|
128
|
+
readonly config: ClawMatrixConfig;
|
|
129
|
+
readonly peerManager: PeerManager;
|
|
130
|
+
readonly handoffManager: HandoffManager;
|
|
131
|
+
readonly toolProxy: ToolProxy;
|
|
132
|
+
readonly modelProxy: ModelProxy;
|
|
133
|
+
/** Frames received via the dispatch wiring (for assertions). */
|
|
134
|
+
readonly receivedFrames: AnyClusterFrame[] = [];
|
|
135
|
+
|
|
136
|
+
constructor(options: TestNodeOptions) {
|
|
137
|
+
this.config = buildConfig(options);
|
|
138
|
+
|
|
139
|
+
const gatewayInfo: GatewayInfo = options.gatewayInfo ?? { port: 0 };
|
|
140
|
+
const logger = createTestLogger();
|
|
141
|
+
|
|
142
|
+
this.peerManager = new PeerManager(this.config);
|
|
143
|
+
this.handoffManager = new HandoffManager(this.config, this.peerManager, gatewayInfo);
|
|
144
|
+
this.toolProxy = new ToolProxy(this.config, this.peerManager, gatewayInfo, logger);
|
|
145
|
+
this.modelProxy = new ModelProxy(this.config, this.peerManager, gatewayInfo, {} as any);
|
|
146
|
+
|
|
147
|
+
// Wire frame dispatch (mirrors ClusterRuntime.dispatchFrame)
|
|
148
|
+
this.peerManager.on("frame", (frame) => {
|
|
149
|
+
this.receivedFrames.push(frame);
|
|
150
|
+
this.dispatchFrame(frame);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private dispatchFrame(frame: AnyClusterFrame): void {
|
|
155
|
+
switch (frame.type) {
|
|
156
|
+
case "handoff_req":
|
|
157
|
+
this.handoffManager.handleRequest(frame as any).catch(() => {});
|
|
158
|
+
break;
|
|
159
|
+
case "handoff_stream":
|
|
160
|
+
this.handoffManager.handleStream(frame as any);
|
|
161
|
+
break;
|
|
162
|
+
case "handoff_res":
|
|
163
|
+
this.handoffManager.handleResponse(frame as any);
|
|
164
|
+
break;
|
|
165
|
+
case "handoff_cancel":
|
|
166
|
+
this.handoffManager.handleCancel(frame as any);
|
|
167
|
+
break;
|
|
168
|
+
case "handoff_input_required":
|
|
169
|
+
this.handoffManager.handleInputRequired(frame as any);
|
|
170
|
+
break;
|
|
171
|
+
case "handoff_input":
|
|
172
|
+
this.handoffManager.handleInput(frame as any).catch(() => {});
|
|
173
|
+
break;
|
|
174
|
+
case "model_req":
|
|
175
|
+
this.modelProxy.handleModelRequest(frame as any).catch(() => {});
|
|
176
|
+
break;
|
|
177
|
+
case "model_res":
|
|
178
|
+
this.modelProxy.handleModelResponse(frame as any);
|
|
179
|
+
break;
|
|
180
|
+
case "model_stream":
|
|
181
|
+
this.modelProxy.handleModelStream(frame as any);
|
|
182
|
+
break;
|
|
183
|
+
case "tool_req":
|
|
184
|
+
this.toolProxy.handleRequest(frame as any).catch(() => {});
|
|
185
|
+
break;
|
|
186
|
+
case "tool_res":
|
|
187
|
+
this.toolProxy.handleResponse(frame as any);
|
|
188
|
+
break;
|
|
189
|
+
case "tool_batch_req":
|
|
190
|
+
this.toolProxy.handleBatchRequest(frame as any).catch(() => {});
|
|
191
|
+
break;
|
|
192
|
+
case "tool_batch_res":
|
|
193
|
+
this.toolProxy.handleBatchResponse(frame as any);
|
|
194
|
+
break;
|
|
195
|
+
default:
|
|
196
|
+
// Unhandled frame types are silently ignored in tests
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Start the node (model proxy HTTP + peer manager WebSocket). */
|
|
202
|
+
async start(): Promise<void> {
|
|
203
|
+
this.modelProxy.start();
|
|
204
|
+
await this.peerManager.start();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Gracefully shut down all subsystems. */
|
|
208
|
+
async stop(): Promise<void> {
|
|
209
|
+
this.handoffManager.destroy();
|
|
210
|
+
this.toolProxy.destroy();
|
|
211
|
+
this.modelProxy.stop();
|
|
212
|
+
await this.peerManager.stop();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Mesh helpers ────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Wait until two nodes have established a WebSocket connection to each other.
|
|
220
|
+
* Resolves when both sides have fired `peerConnected`.
|
|
221
|
+
*/
|
|
222
|
+
export async function waitForConnection(a: TestNode, b: TestNode, timeout = 5000): Promise<void> {
|
|
223
|
+
const aKnowsB = a.peerManager.router.getRoute(b.config.nodeId) !== undefined;
|
|
224
|
+
const bKnowsA = b.peerManager.router.getRoute(a.config.nodeId) !== undefined;
|
|
225
|
+
if (aKnowsB && bKnowsA) return;
|
|
226
|
+
|
|
227
|
+
await Promise.all([
|
|
228
|
+
aKnowsB
|
|
229
|
+
? Promise.resolve()
|
|
230
|
+
: new Promise<void>((resolve, reject) => {
|
|
231
|
+
const timer = setTimeout(() => reject(new Error(`waitForConnection: ${a.config.nodeId} did not see ${b.config.nodeId} within ${timeout}ms`)), timeout);
|
|
232
|
+
const handler = (nodeId: string) => {
|
|
233
|
+
if (nodeId === b.config.nodeId) {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
a.peerManager.off("peerConnected", handler);
|
|
236
|
+
resolve();
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
a.peerManager.on("peerConnected", handler);
|
|
240
|
+
}),
|
|
241
|
+
bKnowsA
|
|
242
|
+
? Promise.resolve()
|
|
243
|
+
: new Promise<void>((resolve, reject) => {
|
|
244
|
+
const timer = setTimeout(() => reject(new Error(`waitForConnection: ${b.config.nodeId} did not see ${a.config.nodeId} within ${timeout}ms`)), timeout);
|
|
245
|
+
const handler = (nodeId: string) => {
|
|
246
|
+
if (nodeId === a.config.nodeId) {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
b.peerManager.off("peerConnected", handler);
|
|
249
|
+
resolve();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
b.peerManager.on("peerConnected", handler);
|
|
253
|
+
}),
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Wait until every node in the list knows about every other node via gossip.
|
|
259
|
+
* Polls the routing table at short intervals.
|
|
260
|
+
*/
|
|
261
|
+
export async function waitForMesh(nodes: TestNode[], timeout = 5000): Promise<void> {
|
|
262
|
+
const deadline = Date.now() + timeout;
|
|
263
|
+
|
|
264
|
+
while (Date.now() < deadline) {
|
|
265
|
+
let complete = true;
|
|
266
|
+
for (const node of nodes) {
|
|
267
|
+
for (const other of nodes) {
|
|
268
|
+
if (node === other) continue;
|
|
269
|
+
if (!node.peerManager.router.getRoute(other.config.nodeId)) {
|
|
270
|
+
complete = false;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!complete) break;
|
|
275
|
+
}
|
|
276
|
+
if (complete) return;
|
|
277
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Build a diagnostic message
|
|
281
|
+
const missing: string[] = [];
|
|
282
|
+
for (const node of nodes) {
|
|
283
|
+
for (const other of nodes) {
|
|
284
|
+
if (node === other) continue;
|
|
285
|
+
if (!node.peerManager.router.getRoute(other.config.nodeId)) {
|
|
286
|
+
missing.push(`${node.config.nodeId} -> ${other.config.nodeId}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throw new Error(`waitForMesh: timed out after ${timeout}ms. Missing routes: ${missing.join(", ")}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Mock HTTP server ────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export interface MockHttpServer {
|
|
296
|
+
url: string;
|
|
297
|
+
port: number;
|
|
298
|
+
close: () => void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create a mock HTTP server using Bun.serve on a random port.
|
|
303
|
+
* Useful for faking gateway API or model API responses in E2E tests.
|
|
304
|
+
*/
|
|
305
|
+
export async function createMockHttpServer(
|
|
306
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
307
|
+
): Promise<MockHttpServer> {
|
|
308
|
+
const server = Bun.serve({
|
|
309
|
+
port: 0, // random available port
|
|
310
|
+
fetch: handler,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
url: `http://localhost:${server.port}`,
|
|
315
|
+
port: server.port,
|
|
316
|
+
close: () => server.stop(true),
|
|
317
|
+
};
|
|
318
|
+
}
|