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/router.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo } from "./types.ts";
|
|
1
|
+
import type { ClusterFrame, AnyClusterFrame, PeerInfo, AgentInfo, ModelInfo, DeviceInfo, ToolProxyInfo, AcpAgentInfo } from "./types.ts";
|
|
2
2
|
import type { Connection } from "./connection.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_TTL = 3;
|
|
@@ -17,6 +17,7 @@ export interface RouteEntry {
|
|
|
17
17
|
directPeers: string[]; // nodeIds this node has direct connections to
|
|
18
18
|
deviceInfo?: DeviceInfo;
|
|
19
19
|
toolProxy?: ToolProxyInfo;
|
|
20
|
+
acpAgents?: AcpAgentInfo[];
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export class Router {
|
|
@@ -26,6 +27,7 @@ export class Router {
|
|
|
26
27
|
private localTags: string[];
|
|
27
28
|
private localDeviceInfo?: DeviceInfo;
|
|
28
29
|
private localToolProxy?: ToolProxyInfo;
|
|
30
|
+
private localAcpAgents?: AcpAgentInfo[];
|
|
29
31
|
private routes = new Map<string, RouteEntry>();
|
|
30
32
|
private connections = new Map<string, Connection>(); // nodeId → direct connection
|
|
31
33
|
/** Double-map dedup: current window + previous window. Rotated periodically. */
|
|
@@ -37,7 +39,7 @@ export class Router {
|
|
|
37
39
|
|
|
38
40
|
constructor(
|
|
39
41
|
nodeId: string,
|
|
40
|
-
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
42
|
+
localCapabilities?: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
|
|
41
43
|
) {
|
|
42
44
|
this.nodeId = nodeId;
|
|
43
45
|
this.localAgents = localCapabilities?.agents ?? [];
|
|
@@ -45,6 +47,7 @@ export class Router {
|
|
|
45
47
|
this.localTags = localCapabilities?.tags ?? [];
|
|
46
48
|
this.localDeviceInfo = localCapabilities?.deviceInfo;
|
|
47
49
|
this.localToolProxy = localCapabilities?.toolProxy;
|
|
50
|
+
this.localAcpAgents = localCapabilities?.acpAgents;
|
|
48
51
|
|
|
49
52
|
this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
|
|
50
53
|
}
|
|
@@ -64,7 +67,7 @@ export class Router {
|
|
|
64
67
|
addDirectPeer(
|
|
65
68
|
nodeId: string,
|
|
66
69
|
connection: Connection,
|
|
67
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
70
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
|
|
68
71
|
) {
|
|
69
72
|
this.connections.set(nodeId, connection);
|
|
70
73
|
this.routes.set(nodeId, {
|
|
@@ -79,6 +82,7 @@ export class Router {
|
|
|
79
82
|
directPeers: [],
|
|
80
83
|
deviceInfo: capabilities.deviceInfo,
|
|
81
84
|
toolProxy: capabilities.toolProxy,
|
|
85
|
+
acpAgents: capabilities.acpAgents,
|
|
82
86
|
});
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -89,6 +93,10 @@ export class Router {
|
|
|
89
93
|
const existing = this.routes.get(peer.nodeId);
|
|
90
94
|
if (existing?.connection) return;
|
|
91
95
|
|
|
96
|
+
// Estimate relay latency: local→relay RTT + relay→target RTT (from peer_sync)
|
|
97
|
+
const relayRoute = this.routes.get(viaNodeId);
|
|
98
|
+
const estimatedLatency = (relayRoute?.latencyMs ?? 0) + (peer.latencyMs ?? 0);
|
|
99
|
+
|
|
92
100
|
this.routes.set(peer.nodeId, {
|
|
93
101
|
nodeId: peer.nodeId,
|
|
94
102
|
agents: peer.agents,
|
|
@@ -97,10 +105,11 @@ export class Router {
|
|
|
97
105
|
connection: null,
|
|
98
106
|
reachableVia: viaNodeId,
|
|
99
107
|
lastSeen: Date.now(),
|
|
100
|
-
latencyMs:
|
|
108
|
+
latencyMs: estimatedLatency,
|
|
101
109
|
directPeers: peer.directPeers ?? [],
|
|
102
110
|
deviceInfo: peer.deviceInfo,
|
|
103
111
|
toolProxy: peer.toolProxy,
|
|
112
|
+
acpAgents: peer.acpAgents,
|
|
104
113
|
});
|
|
105
114
|
}
|
|
106
115
|
|
|
@@ -117,7 +126,7 @@ export class Router {
|
|
|
117
126
|
|
|
118
127
|
updatePeerCapabilities(
|
|
119
128
|
nodeId: string,
|
|
120
|
-
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo },
|
|
129
|
+
capabilities: { agents: AgentInfo[]; models: ModelInfo[]; tags: string[]; directPeers?: string[]; deviceInfo?: DeviceInfo; toolProxy?: ToolProxyInfo; acpAgents?: AcpAgentInfo[] },
|
|
121
130
|
) {
|
|
122
131
|
const entry = this.routes.get(nodeId);
|
|
123
132
|
if (entry) {
|
|
@@ -131,6 +140,7 @@ export class Router {
|
|
|
131
140
|
entry.deviceInfo = capabilities.deviceInfo;
|
|
132
141
|
}
|
|
133
142
|
entry.toolProxy = capabilities.toolProxy;
|
|
143
|
+
entry.acpAgents = capabilities.acpAgents;
|
|
134
144
|
entry.lastSeen = Date.now();
|
|
135
145
|
}
|
|
136
146
|
}
|
|
@@ -189,15 +199,21 @@ export class Router {
|
|
|
189
199
|
resolveNode(target: string): RouteEntry | undefined {
|
|
190
200
|
if (target.startsWith("tags:")) {
|
|
191
201
|
const tag = target.slice(5);
|
|
192
|
-
|
|
202
|
+
const candidates: RouteEntry[] = [];
|
|
193
203
|
for (const entry of this.routes.values()) {
|
|
194
204
|
if (entry.tags.includes(tag)) {
|
|
195
|
-
|
|
196
|
-
best = entry;
|
|
197
|
-
}
|
|
205
|
+
candidates.push(entry);
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
|
-
return
|
|
208
|
+
if (candidates.length === 0) return undefined;
|
|
209
|
+
// Sort: direct connections first, then by latency
|
|
210
|
+
candidates.sort((a, b) => {
|
|
211
|
+
const aDirect = a.connection ? 0 : 1;
|
|
212
|
+
const bDirect = b.connection ? 0 : 1;
|
|
213
|
+
if (aDirect !== bDirect) return aDirect - bDirect;
|
|
214
|
+
return a.latencyMs - b.latencyMs;
|
|
215
|
+
});
|
|
216
|
+
return candidates[0];
|
|
201
217
|
}
|
|
202
218
|
return this.routes.get(target);
|
|
203
219
|
}
|
|
@@ -330,8 +346,12 @@ export class Router {
|
|
|
330
346
|
directPeers: myDirectPeers,
|
|
331
347
|
deviceInfo: this.localDeviceInfo,
|
|
332
348
|
toolProxy: this.localToolProxy,
|
|
349
|
+
acpAgents: this.localAcpAgents,
|
|
333
350
|
});
|
|
334
351
|
for (const entry of this.routes.values()) {
|
|
352
|
+
// Same-nodeId 本地客户端(Mac/iOS)不出现在 peer_sync 中,
|
|
353
|
+
// 客户端通过 auth_ok 获取网关 capabilities,无需在此重复。
|
|
354
|
+
if (entry.nodeId === this.nodeId) continue;
|
|
335
355
|
peers.push({
|
|
336
356
|
nodeId: entry.nodeId,
|
|
337
357
|
agents: entry.agents,
|
|
@@ -341,6 +361,8 @@ export class Router {
|
|
|
341
361
|
directPeers: entry.directPeers.length > 0 ? entry.directPeers : undefined,
|
|
342
362
|
deviceInfo: entry.deviceInfo,
|
|
343
363
|
toolProxy: entry.toolProxy,
|
|
364
|
+
acpAgents: entry.acpAgents,
|
|
365
|
+
latencyMs: entry.latencyMs > 0 ? entry.latencyMs : undefined,
|
|
344
366
|
});
|
|
345
367
|
}
|
|
346
368
|
return peers;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SentinelManager — spawns and manages the sentinel subprocess from the
|
|
3
|
+
* main ClawMatrix process.
|
|
4
|
+
*
|
|
5
|
+
* The sentinel is a detached child that maintains WS connections independently,
|
|
6
|
+
* surviving OpenClaw crashes so remote nodes can still diagnose the machine.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fork, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { existsSync, readFileSync, mkdirSync, openSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import type { ClawMatrixConfig } from "./config.ts";
|
|
14
|
+
|
|
15
|
+
export class SentinelManager {
|
|
16
|
+
private config: ClawMatrixConfig;
|
|
17
|
+
private child: ChildProcess | null = null;
|
|
18
|
+
private pidFile: string;
|
|
19
|
+
|
|
20
|
+
constructor(config: ClawMatrixConfig) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
// Use ~/.openclaw/ for PID file (process.cwd() may be read-only, e.g. "/")
|
|
23
|
+
const pidDir = join(homedir() || tmpdir(), ".openclaw");
|
|
24
|
+
try { mkdirSync(pidDir, { recursive: true }); } catch { /* exists */ }
|
|
25
|
+
this.pidFile = join(pidDir, `.clawmatrix-sentinel-${config.nodeId}.pid`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
start() {
|
|
29
|
+
// The sentinel script path — resolve relative to this file
|
|
30
|
+
const sentinelPath = join(dirname(new URL(import.meta.url).pathname), "sentinel.ts");
|
|
31
|
+
|
|
32
|
+
// Sentinel stderr goes to a log file instead of a pipe to the parent.
|
|
33
|
+
// A pipe would break when the parent exits, crashing the sentinel.
|
|
34
|
+
const logFile = join(dirname(this.pidFile), `sentinel-${this.config.nodeId}.log`);
|
|
35
|
+
const logFd = openSync(logFile, "a");
|
|
36
|
+
|
|
37
|
+
// Fork with detached + IPC channel
|
|
38
|
+
this.child = fork(sentinelPath, [], {
|
|
39
|
+
detached: true,
|
|
40
|
+
stdio: ["ignore", "ignore", logFd, "ipc"],
|
|
41
|
+
// Use the same Node.js execPath; tsx/ts-node loader from parent is inherited
|
|
42
|
+
execArgv: this.resolveExecArgv(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Send config to sentinel via IPC (includes gateway PID for health checks)
|
|
46
|
+
// If sentinel has no explicit listenPort but the gateway is a listener,
|
|
47
|
+
// inherit the gateway's port for automatic takeover when gateway dies.
|
|
48
|
+
const sentinelListenPort = this.config.sentinel?.listenPort
|
|
49
|
+
?? (this.config.listen ? this.config.listenPort : 0);
|
|
50
|
+
this.child.send({
|
|
51
|
+
type: "init",
|
|
52
|
+
config: {
|
|
53
|
+
nodeId: this.config.nodeId,
|
|
54
|
+
secret: this.config.secret,
|
|
55
|
+
peers: this.config.peers,
|
|
56
|
+
agents: this.config.agents,
|
|
57
|
+
models: this.config.models,
|
|
58
|
+
tags: this.config.tags,
|
|
59
|
+
e2ee: this.config.e2ee,
|
|
60
|
+
compression: this.config.compression,
|
|
61
|
+
pidFile: this.pidFile,
|
|
62
|
+
gatewayPid: process.pid,
|
|
63
|
+
listenPort: sentinelListenPort,
|
|
64
|
+
listenHost: this.config.sentinel?.listenHost ?? this.config.listenHost,
|
|
65
|
+
peerApproval: this.config.peerApproval ? {
|
|
66
|
+
enabled: this.config.peerApproval.enabled,
|
|
67
|
+
allowList: this.config.peerApproval.allowList,
|
|
68
|
+
persistPath: this.config.peerApproval.persistPath,
|
|
69
|
+
} : undefined,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Unref so the parent doesn't wait for the sentinel to exit
|
|
74
|
+
this.child.unref();
|
|
75
|
+
// Disconnect IPC so the parent can exit cleanly.
|
|
76
|
+
// The sentinel switches to PID-based health checks on disconnect.
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
try {
|
|
79
|
+
this.child?.disconnect();
|
|
80
|
+
} catch {
|
|
81
|
+
// Already disconnected
|
|
82
|
+
}
|
|
83
|
+
}, 1000);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop() {
|
|
87
|
+
// IPC is disconnected shortly after start, so use PID file for shutdown
|
|
88
|
+
if (existsSync(this.pidFile)) {
|
|
89
|
+
try {
|
|
90
|
+
const pid = parseInt(readFileSync(this.pidFile, "utf-8").trim(), 10);
|
|
91
|
+
if (pid) {
|
|
92
|
+
process.kill(pid, "SIGTERM");
|
|
93
|
+
// Wait briefly for the process to exit so the next start()
|
|
94
|
+
// doesn't race with a still-dying sentinel
|
|
95
|
+
const deadline = Date.now() + 3_000;
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
// Still alive — brief spin
|
|
100
|
+
const waitUntil = Date.now() + 50;
|
|
101
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
102
|
+
} catch {
|
|
103
|
+
break; // exited
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Already gone
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.child = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private resolveExecArgv(): string[] {
|
|
116
|
+
// Inherit TypeScript loaders from the parent process so sentinel.ts can be executed.
|
|
117
|
+
// Common patterns: --loader tsx, --import tsx/esm, --require ts-node/register, etc.
|
|
118
|
+
// We filter in pairs: flag args like "--loader tsx" come as two separate entries,
|
|
119
|
+
// so we keep both the flag and its value when matched.
|
|
120
|
+
const result: string[] = [];
|
|
121
|
+
const argv = process.execArgv;
|
|
122
|
+
for (let i = 0; i < argv.length; i++) {
|
|
123
|
+
const arg = argv[i]!;
|
|
124
|
+
const isLoaderFlag =
|
|
125
|
+
arg === "--loader" || arg.startsWith("--loader=") ||
|
|
126
|
+
arg === "--import" || arg.startsWith("--import=") ||
|
|
127
|
+
arg === "--require" || arg.startsWith("--require=");
|
|
128
|
+
const isTsArg = arg.includes("tsx") || arg.includes("ts-node");
|
|
129
|
+
|
|
130
|
+
if (isLoaderFlag) {
|
|
131
|
+
result.push(arg);
|
|
132
|
+
// If flag doesn't have = value, next arg is the value
|
|
133
|
+
if (!arg.includes("=") && i + 1 < argv.length) {
|
|
134
|
+
result.push(argv[++i]!);
|
|
135
|
+
}
|
|
136
|
+
} else if (isTsArg) {
|
|
137
|
+
result.push(arg);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
}
|