clawmatrix 0.1.23 → 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/src/config.ts CHANGED
@@ -79,31 +79,102 @@ const ProxyModelGroupSchema = z.object({
79
79
 
80
80
  const KnowledgeConfigSchema = z.object({
81
81
  enabled: z.boolean().default(false),
82
+ paths: z.array(z.string()).default([]),
82
83
  debounce: z.number().default(5000),
83
84
  maxFileSize: z.number().default(512 * 1024),
84
85
  }).optional();
85
86
 
87
+ const SentinelConfigSchema = z.object({
88
+ enabled: z.boolean().default(true),
89
+ /** Override port for sentinel listener. Default: inherits gateway's listenPort for automatic takeover. */
90
+ listenPort: z.number().optional(),
91
+ listenHost: z.string().optional(),
92
+ }).optional();
93
+
86
94
  const WebConfigSchema = z.object({
87
95
  enabled: z.boolean().default(false),
88
96
  token: z.string().min(8, "web token must be at least 8 characters"),
89
97
  }).optional();
90
98
 
99
+ const AcpAgentInfoSchema = z.object({
100
+ id: z.string(),
101
+ description: z.string().default(""),
102
+ });
103
+
104
+ const NotifyTargetSchema = z.object({
105
+ channel: z.string(),
106
+ to: z.string(),
107
+ accountId: z.string().optional(),
108
+ threadId: z.union([z.string(), z.number()]).optional(),
109
+ });
110
+
111
+ const PeerApprovalConfigSchema = z.object({
112
+ enabled: z.boolean().default(false),
113
+ /** "notify" = auto-approve + send notification; "required" = block until approved. */
114
+ mode: z.enum(["notify", "required"]).default("notify"),
115
+ /** Approval timeout in ms (required mode only). Default: 20 minutes. */
116
+ timeout: z.number().default(1_200_000),
117
+ /** Pre-approved nodeIds (always allowed without approval/notification). */
118
+ allowList: z.array(z.string()).default([]),
119
+ /** Path to persist approved peers (relative to workspace .clawmatrix/). */
120
+ persistPath: z.string().default("approved-peers.json"),
121
+ /** Notification targets for approval events (e.g. telegram, feishu). */
122
+ notifyTargets: z.array(NotifyTargetSchema).default([]),
123
+ }).optional();
124
+
125
+ const TerminalConfigSchema = z.object({
126
+ enabled: z.boolean().default(true),
127
+ /** Default shell (auto-detected from $SHELL if not set). */
128
+ shell: z.string().optional(),
129
+ /** Idle session timeout in ms (default: 1800000 = 30 min). */
130
+ sessionTTL: z.number().default(1_800_000),
131
+ /** Max concurrent terminal sessions (default: 3). */
132
+ maxSessions: z.number().default(3),
133
+ /** Allowed nodeIds that can open terminals (empty = all authenticated peers). */
134
+ allowFrom: z.array(z.string()).default([]),
135
+ }).optional();
136
+
137
+ const AcpConfigSchema = z.object({
138
+ enabled: z.boolean().default(false),
139
+ /** ACP agents available on this node. Advertised to peers via capabilities. */
140
+ agents: z.array(AcpAgentInfoSchema).default([]),
141
+ /** Override spawn commands per agent name. Default: agent name is used as the binary. */
142
+ commands: z.record(z.string(), z.array(z.string())).default({}),
143
+ /** Per-turn timeout in ms (default: 600000 = 10 min). */
144
+ timeout: z.number().default(600_000),
145
+ /** Idle persistent session TTL in ms (default: 1800000 = 30 min). */
146
+ sessionTTL: z.number().default(1_800_000),
147
+ /** Max concurrent ACP sessions (default: 5). 0 = unlimited. */
148
+ maxSessions: z.number().default(5),
149
+ }).optional();
150
+
91
151
  const RawClawMatrixConfigSchema = z.object({
92
152
  nodeId: z.string(),
93
153
  secret: z.string().min(16, "secret must be at least 16 characters"),
94
154
  listen: z.boolean().default(false),
95
155
  listenHost: z.string().default("0.0.0.0"),
96
- listenPort: z.number().default(19000),
156
+ listenPort: z.number().default(0),
97
157
  peers: z.array(PeerConfigSchema).default([]),
98
158
  agents: z.array(AgentInfoSchema).default([]),
99
159
  models: z.array(ModelInfoSchema).default([]),
100
160
  proxyModels: z.array(ProxyModelGroupSchema).default([]),
101
161
  tags: z.array(z.string()).default([]),
102
- proxyPort: z.number().default(19001),
162
+ proxyPort: z.number().default(0),
103
163
  toolProxy: ToolProxyConfigSchema.optional(),
104
164
  handoffTimeout: z.number().default(600_000),
165
+ modelTimeout: z.number().default(120_000),
166
+ toolTimeout: z.number().default(30_000),
167
+ sentinel: SentinelConfigSchema,
105
168
  web: WebConfigSchema,
106
169
  knowledge: KnowledgeConfigSchema,
170
+ terminal: TerminalConfigSchema,
171
+ acp: AcpConfigSchema,
172
+ peerApproval: z.union([
173
+ z.boolean(), // true = required mode, false = disabled
174
+ PeerApprovalConfigSchema,
175
+ ]).default(true),
176
+ e2ee: z.boolean().default(true),
177
+ compression: z.boolean().default(false),
107
178
  });
108
179
 
109
180
  /** Flat proxy model after group expansion (used internally). */
@@ -121,8 +192,18 @@ export interface ProxyModel {
121
192
  compat?: z.infer<typeof ModelCompatSchema>;
122
193
  }
123
194
 
124
- export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels"> & {
195
+ export interface PeerApprovalConfig {
196
+ enabled: boolean;
197
+ mode: "notify" | "required";
198
+ timeout: number;
199
+ allowList: string[];
200
+ persistPath: string;
201
+ notifyTargets: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }>;
202
+ }
203
+
204
+ export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels" | "peerApproval"> & {
125
205
  proxyModels: ProxyModel[];
206
+ peerApproval: PeerApprovalConfig;
126
207
  };
127
208
 
128
209
  export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
@@ -159,5 +240,37 @@ export function parseConfig(raw: unknown): ClawMatrixConfig {
159
240
  }
160
241
  }
161
242
 
162
- return { ...parsed, proxyModels };
243
+ // Normalize peerApproval: boolean → object
244
+ let peerApproval: PeerApprovalConfig;
245
+ const rawApproval = parsed.peerApproval;
246
+ if (typeof rawApproval === "boolean") {
247
+ peerApproval = {
248
+ enabled: rawApproval,
249
+ mode: "required",
250
+ timeout: 1_200_000,
251
+ allowList: [],
252
+ persistPath: "approved-peers.json",
253
+ notifyTargets: [],
254
+ };
255
+ } else if (rawApproval) {
256
+ peerApproval = {
257
+ enabled: rawApproval.enabled,
258
+ mode: rawApproval.mode,
259
+ timeout: rawApproval.timeout,
260
+ allowList: rawApproval.allowList,
261
+ persistPath: rawApproval.persistPath,
262
+ notifyTargets: rawApproval.notifyTargets,
263
+ };
264
+ } else {
265
+ peerApproval = {
266
+ enabled: false,
267
+ mode: "notify",
268
+ timeout: 1_200_000,
269
+ allowList: [],
270
+ persistPath: "approved-peers.json",
271
+ notifyTargets: [],
272
+ };
273
+ }
274
+
275
+ return { ...parsed, proxyModels, peerApproval };
163
276
  }
package/src/connection.ts CHANGED
@@ -12,6 +12,18 @@ import type {
12
12
  ToolProxyInfo,
13
13
  } from "./types.ts";
14
14
  import { generateNonce, computeHmac, verifyHmac } from "./auth.ts";
15
+ import {
16
+ generateX25519KeyPair,
17
+ deriveSessionKey,
18
+ derivePerPeerSecret,
19
+ encryptBinary,
20
+ decryptBinary,
21
+ isBinaryEncrypted,
22
+ publicKeyToBase64,
23
+ base64ToPublicKey,
24
+ type KeyPair,
25
+ } from "./crypto.ts";
26
+ import { debug } from "./debug.ts";
15
27
 
16
28
  const HEARTBEAT_BASE = 12_000;
17
29
  const HEARTBEAT_JITTER = 6_000;
@@ -35,6 +47,15 @@ export interface ConnectionEvents {
35
47
  error: [error: Error];
36
48
  }
37
49
 
50
+ export interface ConnectionE2eeOptions {
51
+ e2ee?: boolean;
52
+ compression?: boolean;
53
+ /** Defer sending auth_ok for inbound connections until completeAuth() is called. */
54
+ deferAuthOk?: boolean;
55
+ /** Persistent identity key pair (TOFU). If provided, used instead of ephemeral keys. */
56
+ identityKeyPair?: import("./crypto.ts").KeyPair;
57
+ }
58
+
38
59
  export class Connection extends EventEmitter<ConnectionEvents> {
39
60
  readonly role: ConnectionRole;
40
61
  private transport: WsTransport;
@@ -45,8 +66,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
45
66
  remoteNodeId: string | null = null;
46
67
  remoteCapabilities: NodeCapabilities | null = null;
47
68
  authenticated = false;
69
+ /** Whether this connection has an active E2EE session. */
70
+ get encrypted(): boolean { return this.sessionKey !== null; }
71
+ /** Remote peer's persistent public key (available after E2EE handshake). */
72
+ get remoteIdentityKey(): string | null {
73
+ return this._remotePublicKey ? publicKeyToBase64(this._remotePublicKey) : null;
74
+ }
48
75
 
49
76
  private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
77
+ private dummyTimer: ReturnType<typeof setTimeout> | null = null;
50
78
  private authTimer: ReturnType<typeof setTimeout> | null = null;
51
79
  private missedPongs = 0;
52
80
  private pendingNonce: string | null = null;
@@ -55,12 +83,25 @@ export class Connection extends EventEmitter<ConnectionEvents> {
55
83
  /** Exponential moving average of heartbeat RTT in milliseconds. */
56
84
  latencyMs = 0;
57
85
 
86
+ // ── Deferred auth ──────────────────────────────────────────────
87
+ private deferAuthOk: boolean;
88
+ /** Whether auth_ok has been sent (for deferred mode). */
89
+ private authOkSent = false;
90
+
91
+ // ── E2EE state ──────────────────────────────────────────────────
92
+ private e2eeEnabled: boolean;
93
+ private compressionEnabled: boolean;
94
+ private localKeyPair: KeyPair | null = null;
95
+ private _remotePublicKey: Buffer | null = null;
96
+ private sessionKey: Buffer | null = null;
97
+
58
98
  constructor(
59
99
  transport: WsTransport,
60
100
  role: ConnectionRole,
61
101
  nodeId: string,
62
102
  secret: string,
63
103
  localCapabilities: NodeCapabilities,
104
+ e2eeOptions?: ConnectionE2eeOptions,
64
105
  ) {
65
106
  super();
66
107
  this.transport = transport;
@@ -68,6 +109,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
68
109
  this.nodeId = nodeId;
69
110
  this.secret = secret;
70
111
  this.localCapabilities = localCapabilities;
112
+ this.deferAuthOk = e2eeOptions?.deferAuthOk ?? false;
113
+ this.e2eeEnabled = e2eeOptions?.e2ee ?? true;
114
+ this.compressionEnabled = e2eeOptions?.compression ?? false;
115
+
116
+ if (this.e2eeEnabled) {
117
+ // Use persistent identity key pair if provided (TOFU model),
118
+ // otherwise generate an ephemeral key pair per connection.
119
+ this.localKeyPair = e2eeOptions?.identityKeyPair ?? generateX25519KeyPair();
120
+ }
71
121
 
72
122
  // Both inbound and outbound get an auth timeout to prevent hanging connections
73
123
  this.authTimer = setTimeout(() => {
@@ -106,165 +156,295 @@ export class Connection extends EventEmitter<ConnectionEvents> {
106
156
  private async sendAuthChallenge() {
107
157
  const nonce = generateNonce();
108
158
  this.pendingNonce = nonce;
109
- this.sendRaw({
110
- type: "auth_challenge",
111
- from: this.nodeId,
112
- timestamp: Date.now(),
113
- payload: { nonce },
114
- });
159
+ // Minimal obfuscated format: no type/from fields, just short keys
160
+ // n=nonce, k=publicKey (if E2EE), i=nodeId (for per-peer secret derivation)
161
+ const msg: Record<string, string> = { n: nonce, i: this.nodeId };
162
+ if (this.localKeyPair) {
163
+ msg.k = publicKeyToBase64(this.localKeyPair.publicKey);
164
+ }
165
+ this.sendRaw(msg);
115
166
  }
116
167
 
117
168
  // ── Send helpers ───────────────────────────────────────────────
169
+ /** Send a frame, encrypting the entire frame if a session key is established. */
118
170
  send(frame: ClusterFrame | AnyClusterFrame) {
119
171
  if (this.closed) return;
120
- this.sendRaw(frame);
172
+ if (this.sessionKey) {
173
+ // Full frame encryption → binary base64 envelope (no JSON structure on wire)
174
+ this.sendRaw(encryptBinary(this.sessionKey, JSON.stringify(frame), this.compressionEnabled));
175
+ } else {
176
+ this.sendRaw(frame);
177
+ }
121
178
  }
122
179
 
180
+ /** Send raw data. Strings sent as-is (for binary envelopes); objects JSON-encoded. */
123
181
  private sendRaw(data: unknown) {
124
182
  if (this.transport.readyState === WebSocket.OPEN) {
125
- this.transport.send(JSON.stringify(data));
183
+ this.transport.send(typeof data === "string" ? data : JSON.stringify(data));
126
184
  }
127
185
  }
128
186
 
129
187
  // ── Message dispatch ───────────────────────────────────────────
130
188
  private async onRawMessage(data: unknown) {
131
- let frame: AnyClusterFrame;
132
- try {
133
- frame = JSON.parse(typeof data === "string" ? data : String(data));
134
- } catch {
135
- return;
189
+ const str = typeof data === "string" ? data : String(data);
190
+ if (!str.length) return;
191
+
192
+ let frame: AnyClusterFrame | undefined;
193
+
194
+ // Binary encrypted envelope (base64, not JSON)
195
+ if (this.sessionKey && isBinaryEncrypted(str)) {
196
+ try {
197
+ frame = JSON.parse(decryptBinary(this.sessionKey, str));
198
+ } catch (err) {
199
+ debug("e2ee", `Frame decryption failed: ${err}`);
200
+ return;
201
+ }
202
+ // Discard dummy traffic
203
+ if ((frame as any).type === "_d") return;
204
+ }
205
+
206
+ // JSON: handshake messages or plaintext frames
207
+ if (!frame) {
208
+ try {
209
+ frame = JSON.parse(str);
210
+ } catch {
211
+ return;
212
+ }
136
213
  }
137
214
 
138
215
  if (!this.authenticated) {
139
- await this.handleAuthMessage(frame);
216
+ await this.handleAuthMessage(frame!);
140
217
  return;
141
218
  }
142
219
 
143
- if (frame.type === "ping") {
144
- this.sendRaw({
220
+ // Approval pending: HMAC passed but auth_ok not yet sent.
221
+ // Only allow ping/pong — drop all business frames.
222
+ if (!this.authOkSent) {
223
+ if (frame!.type !== "ping" && frame!.type !== "pong") return;
224
+ }
225
+
226
+ if (frame!.type === "ping") {
227
+ this.send({
145
228
  type: "pong",
146
229
  from: this.nodeId,
147
230
  timestamp: Date.now(),
148
- });
231
+ } as AnyClusterFrame);
149
232
  return;
150
233
  }
151
- if (frame.type === "pong") {
234
+ if (frame!.type === "pong") {
152
235
  this.missedPongs = 0;
153
236
  if (this.lastPingSentAt > 0) {
154
237
  const rtt = Date.now() - this.lastPingSentAt;
155
- // Exponential moving average (α = 0.3)
156
238
  this.latencyMs = this.latencyMs === 0 ? rtt : Math.round(this.latencyMs * 0.7 + rtt * 0.3);
157
239
  this.emit("latency", this.latencyMs);
158
240
  }
159
241
  return;
160
242
  }
161
243
 
162
- this.emit("message", frame);
244
+ this.emit("message", frame!);
163
245
  }
164
246
 
165
- private async handleAuthMessage(frame: AnyClusterFrame) {
247
+ private async handleAuthMessage(frame: any) {
166
248
  if (this.role === "inbound") {
249
+ // ── Obfuscated E2EE key exchange: {"k":"<pubkey>"} ──
250
+ if (frame.k && !frame.n && !frame.type) {
251
+ if (this.localKeyPair) {
252
+ this._remotePublicKey = base64ToPublicKey(frame.k);
253
+ const nonceSalt = this.pendingNonce ? Buffer.from(this.pendingNonce) : undefined;
254
+ this.sessionKey = deriveSessionKey(this.localKeyPair, this._remotePublicKey, nonceSalt);
255
+ debug("e2ee", "Session key derived from key exchange");
256
+ }
257
+ return; // Wait for encrypted auth_verify
258
+ }
259
+
260
+ // ── Encrypted auth_verify (decrypted from binary envelope in onRawMessage) ──
261
+ if (frame.type === "auth_verify") {
262
+ const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy, acpAgents } = frame.payload as {
263
+ nodeId: string; sig: string;
264
+ agents?: AgentInfo[]; models?: ModelInfo[]; tags?: string[];
265
+ deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo;
266
+ acpAgents?: import("./types.ts").AcpAgentInfo[];
267
+ };
268
+ if (!this.pendingNonce) return;
269
+
270
+ const peerSecret = derivePerPeerSecret(this.secret, this.nodeId, nodeId);
271
+ const valid = await verifyHmac(this.pendingNonce, peerSecret, sig);
272
+ this.pendingNonce = null;
273
+
274
+ if (!valid) {
275
+ // Minimal error response — no identifiable type strings
276
+ this.sendRaw({ e: "sig" });
277
+ this.close(4001, "auth failed");
278
+ return;
279
+ }
280
+
281
+ this.remoteNodeId = nodeId;
282
+ this.remoteCapabilities = {
283
+ nodeId, agents: agents ?? [], models: models ?? [],
284
+ tags: tags ?? [], deviceInfo, toolProxy, acpAgents,
285
+ };
286
+ this.authenticated = true;
287
+ this.clearAuthTimer();
288
+
289
+ if (this.deferAuthOk) {
290
+ // Don't send auth_ok yet — wait for completeAuth() after approval
291
+ this.emit("authenticated", this.remoteCapabilities);
292
+ return;
293
+ }
294
+
295
+ this.sendAuthOkAndStart();
296
+ this.emit("authenticated", this.remoteCapabilities);
297
+ return;
298
+ }
299
+
300
+ // ── Legacy plaintext auth (E2EE disabled) ──
167
301
  if (frame.type !== "auth") return;
168
302
  const { nodeId, sig, agents, models, tags, deviceInfo, toolProxy } = frame.payload as {
169
- nodeId: string;
170
- sig: string;
171
- agents?: AgentInfo[];
172
- models?: ModelInfo[];
173
- tags?: string[];
174
- deviceInfo?: DeviceInfo;
175
- toolProxy?: ToolProxyInfo;
303
+ nodeId: string; sig: string;
304
+ agents?: AgentInfo[]; models?: ModelInfo[]; tags?: string[];
305
+ deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo;
176
306
  };
177
307
  if (!this.pendingNonce) return;
178
308
 
179
- const valid = await verifyHmac(this.pendingNonce, this.secret, sig);
309
+ const peerSecret = derivePerPeerSecret(this.secret, this.nodeId, nodeId);
310
+ const valid = await verifyHmac(this.pendingNonce, peerSecret, sig);
180
311
  this.pendingNonce = null;
181
312
 
182
313
  if (!valid) {
183
- this.sendRaw({
184
- type: "auth_fail",
185
- from: this.nodeId,
186
- timestamp: Date.now(),
187
- payload: { reason: "Invalid signature" },
188
- } satisfies AuthFail);
314
+ this.sendRaw({ e: "sig" });
189
315
  this.close(4001, "auth failed");
190
316
  return;
191
317
  }
192
318
 
193
319
  this.remoteNodeId = nodeId;
194
320
  this.remoteCapabilities = {
195
- nodeId,
196
- agents: agents ?? [],
197
- models: models ?? [],
198
- tags: tags ?? [],
199
- deviceInfo,
200
- toolProxy,
321
+ nodeId, agents: agents ?? [], models: models ?? [],
322
+ tags: tags ?? [], deviceInfo, toolProxy,
201
323
  };
202
324
  this.authenticated = true;
203
325
  this.clearAuthTimer();
204
326
 
205
- this.sendRaw({
206
- type: "auth_ok",
207
- from: this.nodeId,
208
- timestamp: Date.now(),
209
- payload: {
210
- nodeId: this.nodeId,
211
- agents: this.localCapabilities.agents,
212
- models: this.localCapabilities.models,
213
- tags: this.localCapabilities.tags,
214
- deviceInfo: this.localCapabilities.deviceInfo,
215
- toolProxy: this.localCapabilities.toolProxy,
216
- },
217
- } as AuthOk);
218
-
219
- this.startHeartbeat();
220
- this.emit("authenticated", this.remoteCapabilities);
327
+ if (this.deferAuthOk) {
328
+ this.emit("authenticated", this.remoteCapabilities);
329
+ } else {
330
+ this.sendAuthOkAndStart();
331
+ this.emit("authenticated", this.remoteCapabilities);
332
+ }
221
333
  } else {
222
- if (frame.type === "auth_challenge") {
223
- const { nonce } = (frame as AuthChallenge).payload;
224
- const sig = await computeHmac(nonce, this.secret);
225
- this.sendRaw({
226
- type: "auth",
227
- from: this.nodeId,
228
- timestamp: Date.now(),
229
- payload: {
230
- nodeId: this.nodeId,
231
- sig,
232
- agents: this.localCapabilities.agents,
233
- models: this.localCapabilities.models,
234
- tags: this.localCapabilities.tags,
235
- deviceInfo: this.localCapabilities.deviceInfo,
236
- toolProxy: this.localCapabilities.toolProxy,
237
- },
238
- });
334
+ // ── Outbound (client) ──
335
+
336
+ // Obfuscated challenge: {"n":"<nonce>","k":"<pubkey>","i":"<nodeId>"}
337
+ if (frame.n && !frame.type) {
338
+ const nonce: string = frame.n;
339
+ const publicKey: string | undefined = frame.k;
340
+ const serverNodeId: string = frame.i;
341
+
342
+ if (publicKey && this.localKeyPair) {
343
+ // E2EE: derive session key, send key exchange + encrypted auth_verify
344
+ this._remotePublicKey = base64ToPublicKey(publicKey);
345
+ this.sessionKey = deriveSessionKey(this.localKeyPair, this._remotePublicKey, Buffer.from(nonce));
346
+ debug("e2ee", "Session key derived from challenge");
347
+
348
+ // Key exchange: minimal {"k":"<pubkey>"} (plaintext, server needs it)
349
+ this.sendRaw({ k: publicKeyToBase64(this.localKeyPair.publicKey) });
350
+
351
+ // auth_verify: binary encrypted via send()
352
+ const sig = await computeHmac(nonce, derivePerPeerSecret(this.secret, this.nodeId, serverNodeId));
353
+ this.send({
354
+ type: "auth_verify", from: this.nodeId, timestamp: Date.now(),
355
+ payload: {
356
+ nodeId: this.nodeId, sig,
357
+ agents: this.localCapabilities.agents,
358
+ models: this.localCapabilities.models,
359
+ tags: this.localCapabilities.tags,
360
+ deviceInfo: this.localCapabilities.deviceInfo,
361
+ toolProxy: this.localCapabilities.toolProxy,
362
+ },
363
+ } as AnyClusterFrame);
364
+ } else {
365
+ // Legacy plaintext (no E2EE)
366
+ const sig = await computeHmac(nonce, derivePerPeerSecret(this.secret, this.nodeId, serverNodeId));
367
+ this.sendRaw({
368
+ type: "auth", from: this.nodeId, timestamp: Date.now(),
369
+ payload: {
370
+ nodeId: this.nodeId, sig,
371
+ agents: this.localCapabilities.agents,
372
+ models: this.localCapabilities.models,
373
+ tags: this.localCapabilities.tags,
374
+ deviceInfo: this.localCapabilities.deviceInfo,
375
+ toolProxy: this.localCapabilities.toolProxy,
376
+ },
377
+ });
378
+ }
239
379
  return;
240
380
  }
241
381
 
382
+ // auth_ok (decrypted from binary envelope, or plaintext legacy)
242
383
  if (frame.type === "auth_ok") {
243
384
  const ok = frame as AuthOk;
244
385
  this.remoteNodeId = ok.payload.nodeId;
245
386
  this.remoteCapabilities = {
246
- nodeId: ok.payload.nodeId,
247
- agents: ok.payload.agents,
248
- models: ok.payload.models,
249
- tags: ok.payload.tags,
250
- deviceInfo: ok.payload.deviceInfo,
251
- toolProxy: ok.payload.toolProxy,
387
+ nodeId: ok.payload.nodeId, agents: ok.payload.agents,
388
+ models: ok.payload.models, tags: ok.payload.tags,
389
+ deviceInfo: ok.payload.deviceInfo, toolProxy: ok.payload.toolProxy,
390
+ acpAgents: ok.payload.acpAgents,
252
391
  };
253
392
  this.authenticated = true;
393
+ this.authOkSent = true; // outbound: auth complete, allow business frames
254
394
  this.clearAuthTimer();
395
+
255
396
  this.startHeartbeat();
397
+ this.startDummyTraffic();
256
398
  this.emit("authenticated", this.remoteCapabilities);
257
399
  return;
258
400
  }
259
401
 
260
- if (frame.type === "auth_fail") {
261
- const fail = frame as AuthFail;
262
- this.emit("error", new Error(`Auth failed: ${fail.payload.reason}`));
402
+ // Auth failure: {"e":"sig"} or legacy {"type":"auth_fail",...}
403
+ if (frame.e || frame.type === "auth_fail") {
404
+ const reason = frame.e || (frame.payload?.reason ?? "unknown");
405
+ this.emit("error", new Error(`Auth failed: ${reason}`));
263
406
  this.close(4001, "auth failed");
264
407
  }
265
408
  }
266
409
  }
267
410
 
411
+ /** Send auth_ok with capabilities and start heartbeat/dummy traffic. */
412
+ private sendAuthOkAndStart() {
413
+ if (this.authOkSent) return;
414
+ this.authOkSent = true;
415
+ const authOk = {
416
+ type: "auth_ok", from: this.nodeId, timestamp: Date.now(),
417
+ payload: {
418
+ nodeId: this.nodeId,
419
+ agents: this.localCapabilities.agents,
420
+ models: this.localCapabilities.models,
421
+ tags: this.localCapabilities.tags,
422
+ deviceInfo: this.localCapabilities.deviceInfo,
423
+ toolProxy: this.localCapabilities.toolProxy,
424
+ acpAgents: this.localCapabilities.acpAgents,
425
+ },
426
+ } as AuthOk;
427
+
428
+ if (this.sessionKey) {
429
+ this.send(authOk);
430
+ } else {
431
+ this.sendRaw(authOk);
432
+ }
433
+
434
+ this.startHeartbeat();
435
+ this.startDummyTraffic();
436
+ }
437
+
438
+ /**
439
+ * Complete deferred auth — send auth_ok and start heartbeat.
440
+ * Call this after peer approval succeeds. Only applies to inbound connections
441
+ * with deferAuthOk enabled. No-op otherwise.
442
+ */
443
+ completeAuth() {
444
+ if (!this.authenticated || this.closed || this.role !== "inbound") return;
445
+ this.sendAuthOkAndStart();
446
+ }
447
+
268
448
  private clearAuthTimer() {
269
449
  if (this.authTimer) {
270
450
  clearTimeout(this.authTimer);
@@ -286,11 +466,26 @@ export class Connection extends EventEmitter<ConnectionEvents> {
286
466
  return;
287
467
  }
288
468
  this.lastPingSentAt = Date.now();
289
- this.sendRaw({
469
+ this.send({
290
470
  type: "ping",
291
471
  from: this.nodeId,
292
472
  timestamp: this.lastPingSentAt,
293
- });
473
+ } as AnyClusterFrame);
474
+ scheduleNext();
475
+ }, interval);
476
+ };
477
+ scheduleNext();
478
+ }
479
+
480
+ // ── Dummy traffic (breaks heartbeat timing pattern) ────────────
481
+ private startDummyTraffic() {
482
+ if (!this.sessionKey) return;
483
+ const scheduleNext = () => {
484
+ // Random interval 2-8 seconds — interleaves with heartbeat to obscure pattern
485
+ const interval = 2_000 + Math.random() * 6_000;
486
+ this.dummyTimer = setTimeout(() => {
487
+ if (this.closed || !this.sessionKey) return;
488
+ this.send({ type: "_d", from: "", timestamp: 0 } as unknown as AnyClusterFrame);
294
489
  scheduleNext();
295
490
  }, interval);
296
491
  };
@@ -306,6 +501,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
306
501
  clearTimeout(this.heartbeatTimer);
307
502
  this.heartbeatTimer = null;
308
503
  }
504
+ if (this.dummyTimer) {
505
+ clearTimeout(this.dummyTimer);
506
+ this.dummyTimer = null;
507
+ }
508
+ // Clear session key material (don't null localKeyPair if it's a
509
+ // persistent identity owned by the caller)
510
+ this.sessionKey = null;
511
+ this._remotePublicKey = null;
309
512
  try {
310
513
  this.transport.close(code, reason);
311
514
  } catch {