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/handoff.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { PeerManager } from "./peer-manager.ts";
2
2
  import type { ClawMatrixConfig } from "./config.ts";
3
- import { spawnProcess } from "./compat.ts";
3
+ import type { GatewayInfo } from "./tool-proxy.ts";
4
4
  import type {
5
5
  HandoffRequest,
6
6
  HandoffResponse,
@@ -12,6 +12,7 @@ import type {
12
12
  HandoffInput,
13
13
  HandoffStatus,
14
14
  } from "./types.ts";
15
+ import { TaskActivityBroadcaster } from "./task-activity.ts";
15
16
 
16
17
  const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
17
18
  const MAX_RETRIES = 2;
@@ -32,7 +33,7 @@ interface PendingHandoff {
32
33
  }
33
34
 
34
35
  interface ActiveHandoff {
35
- proc: { kill: () => void; exited: Promise<number> } | null;
36
+ abortController: AbortController | null;
36
37
  status: HandoffStatus;
37
38
  sessionId: string;
38
39
  agent: string;
@@ -44,14 +45,18 @@ interface ActiveHandoff {
44
45
  export class HandoffManager {
45
46
  private config: ClawMatrixConfig;
46
47
  private peerManager: PeerManager;
48
+ private gatewayInfo: GatewayInfo;
47
49
  private pending = new Map<string, PendingHandoff>();
48
50
  private active = new Map<string, ActiveHandoff>();
49
51
  private inputRequiredTargets = new Map<string, string>(); // handoffId → targetNodeId
50
52
  private staleCleanupTimer: ReturnType<typeof setInterval> | null = null;
53
+ private taskActivity: TaskActivityBroadcaster;
51
54
 
52
- constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
55
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
53
56
  this.config = config;
54
57
  this.peerManager = peerManager;
58
+ this.gatewayInfo = gatewayInfo;
59
+ this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
55
60
 
56
61
  // Periodically clean up stale input_required entries
57
62
  this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
@@ -260,13 +265,18 @@ export class HandoffManager {
260
265
  return;
261
266
  }
262
267
 
263
- const sessionId = `handoff-${id}`;
268
+ // Derive a stable session key from agent + requester nodeId.
269
+ // This keeps OpenClaw sessions persistent across handoffs from the same peer,
270
+ // avoiding repeated Session Startup (bootstrap file loading).
271
+ const sessionId = payload.sessionId ?? `handoff-${from}`;
272
+ const sessionKey = `agent:${agent.id}:clawmatrix-handoff:${sessionId}`;
264
273
  const message = payload.context
265
274
  ? `${payload.task}\n\nContext:\n${payload.context}`
266
275
  : payload.task;
276
+ const images = payload.images;
267
277
 
268
278
  const activeEntry: ActiveHandoff = {
269
- proc: null,
279
+ abortController: null,
270
280
  status: "working",
271
281
  sessionId,
272
282
  agent: agent.id,
@@ -276,7 +286,14 @@ export class HandoffManager {
276
286
  };
277
287
  this.active.set(id, activeEntry);
278
288
 
279
- await this.runAgentTurn(id, agent.id, message, sessionId, from, activeEntry);
289
+ // Broadcast task started to mobile nodes
290
+ this.taskActivity.broadcast(id, "handoff", "started", agent.id, activeEntry.startedAt, payload.task.slice(0, 100));
291
+
292
+ // Send the user's message directly — no special startup handling needed.
293
+ // If this is a new OpenClaw session, the agent will execute its Session Startup
294
+ // naturally (reading AGENTS.md, SOUL.md, etc.) before responding, just like
295
+ // Feishu/Telegram do on a new session with a user message.
296
+ await this.runAgentTurn(id, agent.id, message, sessionKey, from, activeEntry, images);
280
297
  }
281
298
 
282
299
  /** Handle incoming handoff_input (resume agent with user reply). */
@@ -288,66 +305,81 @@ export class HandoffManager {
288
305
  if (frame.from !== entry.from) return;
289
306
 
290
307
  entry.status = "working";
291
- await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, entry.sessionId, entry.from, entry);
308
+ const sessionKey = `agent:${entry.agent}:clawmatrix-handoff:${entry.sessionId}`;
309
+ await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, sessionKey, entry.from, entry, frame.payload.images);
292
310
  }
293
311
 
294
- /** Shared logic: spawn agent subprocess, stream output, handle input_required or completion. */
312
+ /** Invoke agent via local gateway HTTP API (/v1/chat/completions). */
295
313
  private async runAgentTurn(
296
314
  id: string,
297
315
  agent: string,
298
316
  message: string,
299
- sessionId: string,
317
+ sessionKey: string,
300
318
  from: string,
301
319
  activeEntry: ActiveHandoff,
320
+ images?: import("./types.ts").ImageContent[],
302
321
  ): Promise<void> {
303
- try {
304
- const proc = spawnProcess(
305
- ["openclaw", "agent", "--agent", agent, "--message", message, "--session-id", sessionId],
306
- { stdout: "pipe", stderr: "pipe" },
307
- );
308
- activeEntry.proc = proc;
322
+ const { port, authHeader } = this.gatewayInfo;
323
+ const abortController = new AbortController();
324
+ activeEntry.abortController = abortController;
309
325
 
310
- // If cancel arrived between active.set() and proc assignment, kill immediately
311
- if (activeEntry.status === "canceled") {
312
- proc.kill();
313
- this.active.delete(id);
314
- return;
326
+ if (activeEntry.status === "canceled") {
327
+ this.active.delete(id);
328
+ return;
329
+ }
330
+
331
+ try {
332
+ const headers: Record<string, string> = {
333
+ "Content-Type": "application/json",
334
+ "X-OpenClaw-Agent-Id": agent,
335
+ "X-OpenClaw-Session-Key": sessionKey,
336
+ "X-OpenClaw-Message-Channel": "clawmatrix",
337
+ };
338
+ if (authHeader) headers["Authorization"] = authHeader;
339
+
340
+ // Build message content: plain string if no images, multimodal array otherwise
341
+ let content: string | unknown[];
342
+ if (images && images.length > 0) {
343
+ content = [
344
+ { type: "text", text: message },
345
+ ...images.map((img) => ({
346
+ type: "image_url",
347
+ image_url: { url: `data:${img.mediaType};base64,${img.data}` },
348
+ })),
349
+ ];
350
+ } else {
351
+ content = message;
315
352
  }
316
353
 
317
- // Consume stderr concurrently to prevent pipe buffer deadlock
318
- const stderrPromise = proc.stderr ? drainStream(proc.stderr) : Promise.resolve("");
319
- const { output, inputRequired } = await this.streamStdout(proc.stdout, id, from);
320
- const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
354
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
355
+ method: "POST",
356
+ headers,
357
+ body: JSON.stringify({
358
+ model: "openclaw",
359
+ stream: true,
360
+ messages: [{ role: "user", content }],
361
+ }),
362
+ signal: abortController.signal,
363
+ });
321
364
 
322
- if (activeEntry.status === "canceled") {
323
- this.active.delete(id);
324
- return;
365
+ if (!res.ok) {
366
+ const text = await res.text();
367
+ throw new Error(`Gateway returned ${res.status}: ${text}`);
325
368
  }
326
369
 
327
- if (inputRequired) {
328
- const marker = "[INPUT_REQUIRED]";
329
- const question = output.slice(output.indexOf(marker) + marker.length).trim();
330
- activeEntry.status = "input_required";
331
- activeEntry.inputRequiredAt = Date.now();
370
+ const output = await this.streamSSE(res, id, from);
332
371
 
333
- this.peerManager.sendTo(from, {
334
- type: "handoff_input_required",
335
- id,
336
- from: this.config.nodeId,
337
- to: from,
338
- timestamp: Date.now(),
339
- payload: { message: question },
340
- } satisfies HandoffInputRequired);
372
+ if (activeEntry.status === "canceled") {
373
+ this.active.delete(id);
341
374
  return;
342
375
  }
343
376
 
344
- if (exitCode !== 0) {
345
- throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
346
- }
347
-
348
377
  activeEntry.status = "completed";
349
378
  this.active.delete(id);
350
379
 
380
+ // Broadcast task completed
381
+ this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
382
+
351
383
  this.peerManager.sendTo(from, {
352
384
  type: "handoff_res",
353
385
  id,
@@ -359,6 +391,7 @@ export class HandoffManager {
359
391
  nodeId: this.config.nodeId,
360
392
  agent,
361
393
  result: output.trim(),
394
+ sessionId: activeEntry.sessionId,
362
395
  },
363
396
  } satisfies HandoffResponse);
364
397
  } catch (err) {
@@ -369,6 +402,12 @@ export class HandoffManager {
369
402
  activeEntry.status = "failed";
370
403
  this.active.delete(id);
371
404
 
405
+ // Broadcast task failed
406
+ this.taskActivity.broadcast(
407
+ id, "handoff", "failed", agent, activeEntry.startedAt,
408
+ err instanceof Error ? err.message : String(err),
409
+ );
410
+
372
411
  this.peerManager.sendTo(from, {
373
412
  type: "handoff_res",
374
413
  id,
@@ -385,44 +424,66 @@ export class HandoffManager {
385
424
  }
386
425
  }
387
426
 
388
- /** Read stdout incrementally, sending handoff_stream chunks to the caller. */
389
- private async streamStdout(
390
- stdout: ReadableStream | null,
427
+ /** Parse SSE streaming response and relay as handoff_stream chunks. */
428
+ private async streamSSE(
429
+ res: Response,
391
430
  handoffId: string,
392
431
  to: string,
393
- ): Promise<{ output: string; inputRequired: boolean }> {
394
- if (!stdout) return { output: "", inputRequired: false };
432
+ ): Promise<string> {
433
+ const body = res.body;
434
+ if (!body) return "";
395
435
 
396
- const reader = stdout.getReader();
436
+ const reader = body.getReader();
397
437
  const decoder = new TextDecoder();
398
438
  let full = "";
439
+ let buffer = "";
399
440
 
400
441
  try {
401
442
  while (true) {
402
443
  const { done, value } = await reader.read();
403
444
  if (done) break;
404
445
 
405
- const chunk = decoder.decode(value, { stream: true });
406
- full += chunk;
407
-
408
- this.peerManager.sendTo(to, {
409
- type: "handoff_stream",
410
- id: handoffId,
411
- from: this.config.nodeId,
412
- to,
413
- timestamp: Date.now(),
414
- payload: { delta: chunk, done: false },
415
- } satisfies HandoffStreamChunk);
446
+ buffer += decoder.decode(value, { stream: true });
447
+ const lines = buffer.split("\n");
448
+ buffer = lines.pop()!; // keep incomplete line
449
+
450
+ for (const line of lines) {
451
+ if (!line.startsWith("data: ")) continue;
452
+ const data = line.slice(6);
453
+ if (data === "[DONE]") continue;
454
+
455
+ try {
456
+ const parsed = JSON.parse(data);
457
+ const delta = parsed.choices?.[0]?.delta?.content;
458
+ if (delta) {
459
+ full += delta;
460
+ this.peerManager.sendTo(to, {
461
+ type: "handoff_stream",
462
+ id: handoffId,
463
+ from: this.config.nodeId,
464
+ to,
465
+ timestamp: Date.now(),
466
+ payload: { delta, done: false },
467
+ } satisfies HandoffStreamChunk);
468
+
469
+ // Broadcast progress to mobile nodes (throttled, detail is just
470
+ // a heartbeat — don't send token-level deltas as they're meaningless fragments)
471
+ const activeEntry = this.active.get(handoffId);
472
+ if (activeEntry) {
473
+ this.taskActivity.broadcast(
474
+ handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
475
+ );
476
+ }
477
+ }
478
+ } catch {
479
+ // skip malformed SSE lines
480
+ }
481
+ }
416
482
  }
417
483
  } finally {
418
484
  reader.releaseLock();
419
485
  }
420
486
 
421
- const INPUT_MARKER = "[INPUT_REQUIRED]";
422
- if (full.indexOf(INPUT_MARKER) !== -1) {
423
- return { output: full, inputRequired: true };
424
- }
425
-
426
487
  // Send final done marker
427
488
  this.peerManager.sendTo(to, {
428
489
  type: "handoff_stream",
@@ -433,7 +494,7 @@ export class HandoffManager {
433
494
  payload: { delta: "", done: true },
434
495
  } satisfies HandoffStreamChunk);
435
496
 
436
- return { output: full, inputRequired: false };
497
+ return full;
437
498
  }
438
499
 
439
500
  /** Handle incoming input_required from remote (requester side). */
@@ -496,7 +557,7 @@ export class HandoffManager {
496
557
 
497
558
  const wasInputRequired = entry.status === "input_required";
498
559
  entry.status = "canceled";
499
- entry.proc?.kill();
560
+ entry.abortController?.abort();
500
561
 
501
562
  // If canceled during input_required, no runAgentTurn is running to clean up
502
563
  if (wasInputRequired) {
@@ -572,10 +633,10 @@ export class HandoffManager {
572
633
  }
573
634
  this.pending.clear();
574
635
 
575
- // Kill all active tasks (working and input_required)
636
+ // Abort all active tasks (working and input_required)
576
637
  for (const [, entry] of this.active) {
577
638
  if (entry.status === "working" || entry.status === "input_required") {
578
- entry.proc?.kill();
639
+ entry.abortController?.abort();
579
640
  }
580
641
  }
581
642
  this.active.clear();
@@ -583,19 +644,3 @@ export class HandoffManager {
583
644
  }
584
645
  }
585
646
 
586
- /** Drain a ReadableStream into a string (for stderr consumption). */
587
- async function drainStream(stream: ReadableStream): Promise<string> {
588
- const reader = stream.getReader();
589
- const decoder = new TextDecoder();
590
- let result = "";
591
- try {
592
- while (true) {
593
- const { done, value } = await reader.read();
594
- if (done) break;
595
- result += decoder.decode(value, { stream: true });
596
- }
597
- } finally {
598
- reader.releaseLock();
599
- }
600
- return result;
601
- }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Persistent node identity based on X25519 key pair.
3
+ *
4
+ * ## Security model (TOFU — Trust On First Use)
5
+ *
6
+ * Each node generates a persistent X25519 key pair stored in `.clawmatrix/identity.json`.
7
+ * The public key serves as the node's cryptographic identity:
8
+ *
9
+ * - On first connection, a new peer's public key is recorded during approval.
10
+ * - On subsequent connections, the peer's public key is verified against the
11
+ * approved record. If the key doesn't match, the peer is treated as a new
12
+ * device and must be re-approved.
13
+ * - This prevents nodeId impersonation: even if an attacker obtains the shared
14
+ * secret and sets the same nodeId, they cannot forge the private key, so
15
+ * their public key won't match the approved record.
16
+ *
17
+ * This is the same trust model used by SSH (known_hosts) and Signal (safety
18
+ * numbers). The known limitation is that the very first connection is vulnerable
19
+ * to MITM — after that, the public key is pinned.
20
+ *
21
+ * The key pair also serves as the E2EE session key exchange material. Unlike
22
+ * the previous ephemeral-per-connection design, using a persistent key pair
23
+ * means forward secrecy is reduced (compromising the private key allows
24
+ * decryption of past sessions). This is an acceptable tradeoff for identity
25
+ * binding — and the session key is still unique per connection due to the
26
+ * ECDH exchange with the peer's key.
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import {
32
+ generateX25519KeyPair,
33
+ publicKeyToBase64,
34
+ type KeyPair,
35
+ } from "./crypto.ts";
36
+
37
+ const IDENTITY_FILE = "identity.json";
38
+
39
+ interface IdentityData {
40
+ publicKey: string; // base64
41
+ privateKey: string; // base64 (PKCS8 DER)
42
+ createdAt: number;
43
+ }
44
+
45
+ /**
46
+ * Load or generate a persistent X25519 identity key pair.
47
+ * Stored in `<stateDir>/identity.json`.
48
+ */
49
+ export function loadOrCreateIdentity(stateDir: string): KeyPair {
50
+ const filePath = path.join(stateDir, IDENTITY_FILE);
51
+
52
+ // Try loading existing identity
53
+ try {
54
+ if (fs.existsSync(filePath)) {
55
+ const raw = fs.readFileSync(filePath, "utf-8");
56
+ const data: IdentityData = JSON.parse(raw);
57
+ return keyPairFromSerialized(data.publicKey, data.privateKey);
58
+ }
59
+ } catch {
60
+ // Corrupted file — regenerate
61
+ }
62
+
63
+ // Generate new identity
64
+ const keyPair = generateX25519KeyPair();
65
+ const data: IdentityData = {
66
+ publicKey: publicKeyToBase64(keyPair.publicKey),
67
+ privateKey: keyPair.privateKey.toString("base64"),
68
+ createdAt: Date.now(),
69
+ };
70
+
71
+ // Ensure directory exists
72
+ if (!fs.existsSync(stateDir)) {
73
+ fs.mkdirSync(stateDir, { recursive: true });
74
+ }
75
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
76
+
77
+ return keyPair;
78
+ }
79
+
80
+ /** Reconstruct a KeyPair from serialized base64 strings. */
81
+ function keyPairFromSerialized(publicKeyB64: string, privateKeyB64: string): KeyPair {
82
+ const { createPrivateKey } = require("node:crypto");
83
+ const publicKey = Buffer.from(publicKeyB64, "base64");
84
+ const privateKey = Buffer.from(privateKeyB64, "base64");
85
+
86
+ return {
87
+ publicKey,
88
+ privateKey,
89
+ _privateKeyObject: createPrivateKey({
90
+ key: privateKey,
91
+ format: "der",
92
+ type: "pkcs8",
93
+ }),
94
+ };
95
+ }