clawmatrix 0.1.23 → 0.2.1

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,19 +45,46 @@ 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;
54
+ // Multi-device sync: track which nodes are watching each handoff session (by sessionId)
55
+ private sessionWatchers = new Map<string, Set<string>>();
51
56
 
52
- constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
57
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
53
58
  this.config = config;
54
59
  this.peerManager = peerManager;
60
+ this.gatewayInfo = gatewayInfo;
61
+ this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
55
62
 
56
63
  // Periodically clean up stale input_required entries
57
64
  this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
58
65
  }
59
66
 
67
+ // ── Multi-device sync helpers ──────────────────────────────────
68
+
69
+ private addSessionWatcher(sessionId: string, nodeId: string) {
70
+ let watchers = this.sessionWatchers.get(sessionId);
71
+ if (!watchers) {
72
+ watchers = new Set();
73
+ this.sessionWatchers.set(sessionId, watchers);
74
+ }
75
+ watchers.add(nodeId);
76
+ }
77
+
78
+ /** Send a handoff frame to all session watchers except the specified node. */
79
+ private sendToOtherWatchers(sessionId: string, exclude: string, frame: HandoffStreamChunk | HandoffResponse | HandoffInputRequired) {
80
+ const watchers = this.sessionWatchers.get(sessionId);
81
+ if (!watchers) return;
82
+ for (const nodeId of watchers) {
83
+ if (nodeId === exclude) continue;
84
+ this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
85
+ }
86
+ }
87
+
60
88
  /** Remove stale input_required and canceled entries that exceeded TTL. */
61
89
  private cleanupStale() {
62
90
  const now = Date.now();
@@ -260,13 +288,21 @@ export class HandoffManager {
260
288
  return;
261
289
  }
262
290
 
263
- const sessionId = `handoff-${id}`;
291
+ // Derive a stable session key from agent + requester nodeId.
292
+ // This keeps OpenClaw sessions persistent across handoffs from the same peer,
293
+ // avoiding repeated Session Startup (bootstrap file loading).
294
+ const sessionId = payload.sessionId ?? `handoff-${from}`;
295
+ const sessionKey = `agent:${agent.id}:clawmatrix-handoff:${sessionId}`;
264
296
  const message = payload.context
265
297
  ? `${payload.task}\n\nContext:\n${payload.context}`
266
298
  : payload.task;
299
+ const images = payload.images;
300
+
301
+ // Track this node as a session watcher for multi-device sync
302
+ this.addSessionWatcher(sessionId, from);
267
303
 
268
304
  const activeEntry: ActiveHandoff = {
269
- proc: null,
305
+ abortController: null,
270
306
  status: "working",
271
307
  sessionId,
272
308
  agent: agent.id,
@@ -276,7 +312,14 @@ export class HandoffManager {
276
312
  };
277
313
  this.active.set(id, activeEntry);
278
314
 
279
- await this.runAgentTurn(id, agent.id, message, sessionId, from, activeEntry);
315
+ // Broadcast task started to mobile nodes
316
+ this.taskActivity.broadcast(id, "handoff", "started", agent.id, activeEntry.startedAt, payload.task.slice(0, 100));
317
+
318
+ // Send the user's message directly — no special startup handling needed.
319
+ // If this is a new OpenClaw session, the agent will execute its Session Startup
320
+ // naturally (reading AGENTS.md, SOUL.md, etc.) before responding, just like
321
+ // Feishu/Telegram do on a new session with a user message.
322
+ await this.runAgentTurn(id, agent.id, message, sessionKey, from, activeEntry, images);
280
323
  }
281
324
 
282
325
  /** Handle incoming handoff_input (resume agent with user reply). */
@@ -288,67 +331,82 @@ export class HandoffManager {
288
331
  if (frame.from !== entry.from) return;
289
332
 
290
333
  entry.status = "working";
291
- await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, entry.sessionId, entry.from, entry);
334
+ const sessionKey = `agent:${entry.agent}:clawmatrix-handoff:${entry.sessionId}`;
335
+ await this.runAgentTurn(frame.id, entry.agent, frame.payload.message, sessionKey, entry.from, entry, frame.payload.images);
292
336
  }
293
337
 
294
- /** Shared logic: spawn agent subprocess, stream output, handle input_required or completion. */
338
+ /** Invoke agent via local gateway HTTP API (/v1/chat/completions). */
295
339
  private async runAgentTurn(
296
340
  id: string,
297
341
  agent: string,
298
342
  message: string,
299
- sessionId: string,
343
+ sessionKey: string,
300
344
  from: string,
301
345
  activeEntry: ActiveHandoff,
346
+ images?: import("./types.ts").ImageContent[],
302
347
  ): 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;
348
+ const { port, authHeader } = this.gatewayInfo;
349
+ const abortController = new AbortController();
350
+ activeEntry.abortController = abortController;
309
351
 
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;
352
+ if (activeEntry.status === "canceled") {
353
+ this.active.delete(id);
354
+ return;
355
+ }
356
+
357
+ try {
358
+ const headers: Record<string, string> = {
359
+ "Content-Type": "application/json",
360
+ "X-OpenClaw-Agent-Id": agent,
361
+ "X-OpenClaw-Session-Key": sessionKey,
362
+ "X-OpenClaw-Message-Channel": "clawmatrix",
363
+ };
364
+ if (authHeader) headers["Authorization"] = authHeader;
365
+
366
+ // Build message content: plain string if no images, multimodal array otherwise
367
+ let content: string | unknown[];
368
+ if (images && images.length > 0) {
369
+ content = [
370
+ { type: "text", text: message },
371
+ ...images.map((img) => ({
372
+ type: "image_url",
373
+ image_url: { url: `data:${img.mediaType};base64,${img.data}` },
374
+ })),
375
+ ];
376
+ } else {
377
+ content = message;
315
378
  }
316
379
 
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]);
380
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
381
+ method: "POST",
382
+ headers,
383
+ body: JSON.stringify({
384
+ model: "openclaw",
385
+ stream: true,
386
+ messages: [{ role: "user", content }],
387
+ }),
388
+ signal: abortController.signal,
389
+ });
321
390
 
322
- if (activeEntry.status === "canceled") {
323
- this.active.delete(id);
324
- return;
391
+ if (!res.ok) {
392
+ const text = await res.text();
393
+ throw new Error(`Gateway returned ${res.status}: ${text}`);
325
394
  }
326
395
 
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();
396
+ const output = await this.streamSSE(res, id, from, activeEntry.sessionId);
332
397
 
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);
398
+ if (activeEntry.status === "canceled") {
399
+ this.active.delete(id);
341
400
  return;
342
401
  }
343
402
 
344
- if (exitCode !== 0) {
345
- throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
346
- }
347
-
348
403
  activeEntry.status = "completed";
349
404
  this.active.delete(id);
350
405
 
351
- this.peerManager.sendTo(from, {
406
+ // Broadcast task completed
407
+ this.taskActivity.broadcast(id, "handoff", "completed", agent, activeEntry.startedAt);
408
+
409
+ const resFrame: HandoffResponse = {
352
410
  type: "handoff_res",
353
411
  id,
354
412
  from: this.config.nodeId,
@@ -359,8 +417,11 @@ export class HandoffManager {
359
417
  nodeId: this.config.nodeId,
360
418
  agent,
361
419
  result: output.trim(),
420
+ sessionId: activeEntry.sessionId,
362
421
  },
363
- } satisfies HandoffResponse);
422
+ };
423
+ this.peerManager.sendTo(from, resFrame);
424
+ this.sendToOtherWatchers(activeEntry.sessionId, from, resFrame);
364
425
  } catch (err) {
365
426
  if (activeEntry.status === "canceled") {
366
427
  this.active.delete(id);
@@ -369,6 +430,12 @@ export class HandoffManager {
369
430
  activeEntry.status = "failed";
370
431
  this.active.delete(id);
371
432
 
433
+ // Broadcast task failed
434
+ this.taskActivity.broadcast(
435
+ id, "handoff", "failed", agent, activeEntry.startedAt,
436
+ err instanceof Error ? err.message : String(err),
437
+ );
438
+
372
439
  this.peerManager.sendTo(from, {
373
440
  type: "handoff_res",
374
441
  id,
@@ -385,55 +452,82 @@ export class HandoffManager {
385
452
  }
386
453
  }
387
454
 
388
- /** Read stdout incrementally, sending handoff_stream chunks to the caller. */
389
- private async streamStdout(
390
- stdout: ReadableStream | null,
455
+ /** Parse SSE streaming response and relay as handoff_stream chunks. */
456
+ private async streamSSE(
457
+ res: Response,
391
458
  handoffId: string,
392
459
  to: string,
393
- ): Promise<{ output: string; inputRequired: boolean }> {
394
- if (!stdout) return { output: "", inputRequired: false };
460
+ sessionId?: string,
461
+ ): Promise<string> {
462
+ const body = res.body;
463
+ if (!body) return "";
395
464
 
396
- const reader = stdout.getReader();
465
+ const reader = body.getReader();
397
466
  const decoder = new TextDecoder();
398
467
  let full = "";
468
+ let buffer = "";
399
469
 
400
470
  try {
401
471
  while (true) {
402
472
  const { done, value } = await reader.read();
403
473
  if (done) break;
404
474
 
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);
475
+ buffer += decoder.decode(value, { stream: true });
476
+ const lines = buffer.split("\n");
477
+ buffer = lines.pop()!; // keep incomplete line
478
+
479
+ for (const line of lines) {
480
+ if (!line.startsWith("data: ")) continue;
481
+ const data = line.slice(6);
482
+ if (data === "[DONE]") continue;
483
+
484
+ try {
485
+ const parsed = JSON.parse(data);
486
+ const delta = parsed.choices?.[0]?.delta?.content;
487
+ if (delta) {
488
+ full += delta;
489
+ const streamFrame: HandoffStreamChunk = {
490
+ type: "handoff_stream",
491
+ id: handoffId,
492
+ from: this.config.nodeId,
493
+ to,
494
+ timestamp: Date.now(),
495
+ payload: { delta, done: false, sessionId },
496
+ };
497
+ this.peerManager.sendTo(to, streamFrame);
498
+ if (sessionId) this.sendToOtherWatchers(sessionId, to, streamFrame);
499
+
500
+ // Broadcast progress to mobile nodes (throttled, detail is just
501
+ // a heartbeat — don't send token-level deltas as they're meaningless fragments)
502
+ const activeEntry = this.active.get(handoffId);
503
+ if (activeEntry) {
504
+ this.taskActivity.broadcast(
505
+ handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
506
+ );
507
+ }
508
+ }
509
+ } catch {
510
+ // skip malformed SSE lines
511
+ }
512
+ }
416
513
  }
417
514
  } finally {
418
515
  reader.releaseLock();
419
516
  }
420
517
 
421
- const INPUT_MARKER = "[INPUT_REQUIRED]";
422
- if (full.indexOf(INPUT_MARKER) !== -1) {
423
- return { output: full, inputRequired: true };
424
- }
425
-
426
- // Send final done marker
427
- this.peerManager.sendTo(to, {
518
+ // Send final done marker to requester + watchers
519
+ const doneFrame: HandoffStreamChunk = {
428
520
  type: "handoff_stream",
429
521
  id: handoffId,
430
522
  from: this.config.nodeId,
431
523
  to,
432
524
  timestamp: Date.now(),
433
- payload: { delta: "", done: true },
434
- } satisfies HandoffStreamChunk);
525
+ payload: { delta: "", done: true, sessionId },
526
+ };
527
+ this.peerManager.sendTo(to, doneFrame);
528
+ if (sessionId) this.sendToOtherWatchers(sessionId, to, doneFrame);
435
529
 
436
- return { output: full, inputRequired: false };
530
+ return full;
437
531
  }
438
532
 
439
533
  /** Handle incoming input_required from remote (requester side). */
@@ -496,7 +590,7 @@ export class HandoffManager {
496
590
 
497
591
  const wasInputRequired = entry.status === "input_required";
498
592
  entry.status = "canceled";
499
- entry.proc?.kill();
593
+ entry.abortController?.abort();
500
594
 
501
595
  // If canceled during input_required, no runAgentTurn is running to clean up
502
596
  if (wasInputRequired) {
@@ -572,30 +666,15 @@ export class HandoffManager {
572
666
  }
573
667
  this.pending.clear();
574
668
 
575
- // Kill all active tasks (working and input_required)
669
+ // Abort all active tasks (working and input_required)
576
670
  for (const [, entry] of this.active) {
577
671
  if (entry.status === "working" || entry.status === "input_required") {
578
- entry.proc?.kill();
672
+ entry.abortController?.abort();
579
673
  }
580
674
  }
581
675
  this.active.clear();
582
676
  this.inputRequiredTargets.clear();
677
+ this.sessionWatchers.clear();
583
678
  }
584
679
  }
585
680
 
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
+ }