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/audit.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Structured audit logger for security events. */
|
|
2
|
+
|
|
3
|
+
export type AuditEvent =
|
|
4
|
+
| "conn_open" // New inbound WS connection
|
|
5
|
+
| "conn_rate_limited" // Connection rejected by rate limiter
|
|
6
|
+
| "auth_success" // HMAC authentication succeeded
|
|
7
|
+
| "auth_failure" // HMAC authentication failed
|
|
8
|
+
| "auth_timeout" // Authentication timed out
|
|
9
|
+
| "peer_join" // Peer joined the mesh
|
|
10
|
+
| "peer_leave" // Peer left / disconnected
|
|
11
|
+
| "conn_close"; // Connection closed
|
|
12
|
+
|
|
13
|
+
export interface AuditEntry {
|
|
14
|
+
ts: string; // ISO timestamp
|
|
15
|
+
event: AuditEvent;
|
|
16
|
+
ip?: string; // Remote IP (if available)
|
|
17
|
+
nodeId?: string; // Remote nodeId (if known)
|
|
18
|
+
detail?: string; // Extra context
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AuditSink = (entry: AuditEntry) => void;
|
|
22
|
+
|
|
23
|
+
/** Default sink: structured JSON to stderr via console.warn (won't mix with stdout output). */
|
|
24
|
+
const defaultSink: AuditSink = (entry) => {
|
|
25
|
+
console.warn(`[clawmatrix:audit] ${JSON.stringify(entry)}`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let sink: AuditSink = defaultSink;
|
|
29
|
+
|
|
30
|
+
/** Replace the audit sink (e.g. to write to a file or external service). */
|
|
31
|
+
export function setAuditSink(s: AuditSink) {
|
|
32
|
+
sink = s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Emit an audit event. */
|
|
36
|
+
export function audit(event: AuditEvent, fields?: Omit<AuditEntry, "ts" | "event">) {
|
|
37
|
+
sink({
|
|
38
|
+
ts: new Date().toISOString(),
|
|
39
|
+
event,
|
|
40
|
+
...fields,
|
|
41
|
+
});
|
|
42
|
+
}
|
package/src/auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
export function generateNonce(): string {
|
|
2
4
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
3
5
|
return Buffer.from(bytes).toString("hex");
|
|
@@ -8,7 +10,6 @@ const KEY_CACHE_MAX = 8;
|
|
|
8
10
|
const keyCache = new Map<string, CryptoKey>();
|
|
9
11
|
|
|
10
12
|
function cacheKeyFor(secret: string): string {
|
|
11
|
-
const { createHash } = require("node:crypto");
|
|
12
13
|
return createHash("sha256").update(secret).digest("hex");
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -48,13 +49,11 @@ export async function computeHmac(
|
|
|
48
49
|
/** Constant-time string comparison. Uses native crypto.timingSafeEqual with
|
|
49
50
|
* SHA-256 pre-hash to normalize lengths (avoids length-leak from early return). */
|
|
50
51
|
export function timingSafeEqual(a: string, b: string): boolean {
|
|
51
|
-
const nodeTimingSafeEqual = require("node:crypto").timingSafeEqual;
|
|
52
52
|
const encoder = new TextEncoder();
|
|
53
53
|
const bufA = encoder.encode(a);
|
|
54
54
|
const bufB = encoder.encode(b);
|
|
55
55
|
if (bufA.byteLength !== bufB.byteLength) {
|
|
56
56
|
// Hash both to fixed length so we still compare in constant time
|
|
57
|
-
const { createHash } = require("node:crypto");
|
|
58
57
|
const hashA = createHash("sha256").update(bufA).digest();
|
|
59
58
|
const hashB = createHash("sha256").update(bufB).digest();
|
|
60
59
|
return nodeTimingSafeEqual(hashA, hashB);
|
package/src/cli.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { spawnProcess } from "./compat.ts";
|
|
3
3
|
|
|
4
|
-
async function callGateway(method: string): Promise<unknown> {
|
|
5
|
-
const
|
|
4
|
+
async function callGateway(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
5
|
+
const args = ["openclaw", "gateway", "call", method, "--json"];
|
|
6
|
+
if (params) {
|
|
7
|
+
args.push("--params", JSON.stringify(params));
|
|
8
|
+
}
|
|
9
|
+
const proc = spawnProcess(args, {
|
|
6
10
|
stdout: "pipe",
|
|
7
11
|
stderr: "pipe",
|
|
8
12
|
});
|
|
@@ -161,4 +165,74 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
|
|
|
161
165
|
}
|
|
162
166
|
console.log(JSON.stringify(peers, null, 2));
|
|
163
167
|
});
|
|
168
|
+
|
|
169
|
+
// ── Peer approval commands ──────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
cmd
|
|
172
|
+
.command("approve <approvalId>")
|
|
173
|
+
.description("Approve a pending peer join request")
|
|
174
|
+
.action(async (approvalId: string) => {
|
|
175
|
+
try {
|
|
176
|
+
const result = await callGateway("clawmatrix.approval.resolve", {
|
|
177
|
+
approvalId,
|
|
178
|
+
decision: "approve",
|
|
179
|
+
}) as Record<string, unknown>;
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
console.log(`Approved: ${approvalId}`);
|
|
182
|
+
} else {
|
|
183
|
+
console.log(`Approval not found or already resolved: ${approvalId}`);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
console.log("Could not reach gateway. Is it running?");
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
cmd
|
|
191
|
+
.command("deny <approvalId>")
|
|
192
|
+
.description("Deny a pending peer join request")
|
|
193
|
+
.action(async (approvalId: string) => {
|
|
194
|
+
try {
|
|
195
|
+
const result = await callGateway("clawmatrix.approval.resolve", {
|
|
196
|
+
approvalId,
|
|
197
|
+
decision: "deny",
|
|
198
|
+
}) as Record<string, unknown>;
|
|
199
|
+
if (result.ok) {
|
|
200
|
+
console.log(`Denied: ${approvalId}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(`Approval not found or already resolved: ${approvalId}`);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
console.log("Could not reach gateway. Is it running?");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const approval = cmd.command("approval").description("Manage peer approvals");
|
|
210
|
+
|
|
211
|
+
approval
|
|
212
|
+
.command("list")
|
|
213
|
+
.description("List approved and pending peers")
|
|
214
|
+
.action(async () => {
|
|
215
|
+
try {
|
|
216
|
+
const data = await callGateway("clawmatrix.approval.list") as Record<string, unknown>;
|
|
217
|
+
console.log(JSON.stringify(data, null, 2));
|
|
218
|
+
} catch {
|
|
219
|
+
console.log("Could not reach gateway. Is it running?");
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
approval
|
|
224
|
+
.command("revoke <nodeId>")
|
|
225
|
+
.description("Revoke an approved peer")
|
|
226
|
+
.action(async (nodeId: string) => {
|
|
227
|
+
try {
|
|
228
|
+
const result = await callGateway("clawmatrix.approval.revoke", { nodeId }) as Record<string, unknown>;
|
|
229
|
+
if (result.ok) {
|
|
230
|
+
console.log(`Revoked: ${nodeId}`);
|
|
231
|
+
} else {
|
|
232
|
+
console.log(`Node not found in approved list: ${nodeId}`);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
console.log("Could not reach gateway. Is it running?");
|
|
236
|
+
}
|
|
237
|
+
});
|
|
164
238
|
};
|
package/src/cluster-service.ts
CHANGED
|
@@ -12,8 +12,11 @@ import { PeerManager } from "./peer-manager.ts";
|
|
|
12
12
|
import { HandoffManager } from "./handoff.ts";
|
|
13
13
|
import { ModelProxy } from "./model-proxy.ts";
|
|
14
14
|
import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
|
|
15
|
+
import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
|
|
16
|
+
import { TerminalManager } from "./terminal.ts";
|
|
15
17
|
import { WebHandler } from "./web.ts";
|
|
16
18
|
import { KnowledgeSync } from "./knowledge-sync.ts";
|
|
19
|
+
import { SentinelManager } from "./sentinel-manager.ts";
|
|
17
20
|
import type {
|
|
18
21
|
AnyClusterFrame,
|
|
19
22
|
HandoffRequest,
|
|
@@ -31,6 +34,33 @@ import type {
|
|
|
31
34
|
SendMessage,
|
|
32
35
|
ToolProxyRequest,
|
|
33
36
|
ToolProxyResponse,
|
|
37
|
+
ToolBatchRequest,
|
|
38
|
+
ToolBatchResponse,
|
|
39
|
+
DiagnosticExecResponse,
|
|
40
|
+
DiagnosticStatusResponse,
|
|
41
|
+
AcpTaskRequest,
|
|
42
|
+
AcpTaskResponse,
|
|
43
|
+
AcpStreamChunk,
|
|
44
|
+
AcpCloseRequest,
|
|
45
|
+
AcpCloseResponse,
|
|
46
|
+
AcpListRequest,
|
|
47
|
+
AcpListResponse,
|
|
48
|
+
AcpResumeRequest,
|
|
49
|
+
AcpResumeResponse,
|
|
50
|
+
AcpCancelRequest,
|
|
51
|
+
AcpCancelResponse,
|
|
52
|
+
AcpSetModeRequest,
|
|
53
|
+
AcpSetModeResponse,
|
|
54
|
+
AcpGetModesRequest,
|
|
55
|
+
AcpGetModesResponse,
|
|
56
|
+
ChatHistoryRequest,
|
|
57
|
+
ChatHistoryResponse,
|
|
58
|
+
TerminalOpenRequest,
|
|
59
|
+
TerminalOpenResponse,
|
|
60
|
+
TerminalData,
|
|
61
|
+
TerminalResize,
|
|
62
|
+
TerminalCloseRequest,
|
|
63
|
+
TerminalCloseResponse,
|
|
34
64
|
} from "./types.ts";
|
|
35
65
|
|
|
36
66
|
function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
|
|
@@ -52,10 +82,14 @@ export class ClusterRuntime {
|
|
|
52
82
|
readonly handoffManager: HandoffManager;
|
|
53
83
|
readonly modelProxy: ModelProxy;
|
|
54
84
|
readonly toolProxy: ToolProxy;
|
|
85
|
+
readonly acpProxy: AcpProxy | null;
|
|
86
|
+
readonly terminalManager: TerminalManager;
|
|
55
87
|
knowledgeSync: KnowledgeSync | null = null;
|
|
56
88
|
webHandler: WebHandler | null = null;
|
|
89
|
+
private sentinelManager: SentinelManager | null = null;
|
|
57
90
|
private logger: PluginLogger;
|
|
58
91
|
private openclawConfig: OpenClawConfig;
|
|
92
|
+
private exitHandler: (() => void) | null = null;
|
|
59
93
|
|
|
60
94
|
constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
|
|
61
95
|
this.config = config;
|
|
@@ -63,9 +97,13 @@ export class ClusterRuntime {
|
|
|
63
97
|
this.openclawConfig = openclawConfig;
|
|
64
98
|
const gatewayInfo = resolveGatewayInfo(openclawConfig);
|
|
65
99
|
this.peerManager = new PeerManager(config, openclawVersion);
|
|
66
|
-
this.handoffManager = new HandoffManager(config, this.peerManager);
|
|
100
|
+
this.handoffManager = new HandoffManager(config, this.peerManager, gatewayInfo);
|
|
67
101
|
this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
|
|
68
102
|
this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo, logger);
|
|
103
|
+
// Enable ACP proxy if either ClawMatrix config or OpenClaw config has ACP enabled
|
|
104
|
+
const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
|
|
105
|
+
this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
|
|
106
|
+
this.terminalManager = new TerminalManager(config, this.peerManager);
|
|
69
107
|
}
|
|
70
108
|
|
|
71
109
|
start() {
|
|
@@ -76,10 +114,16 @@ export class ClusterRuntime {
|
|
|
76
114
|
|
|
77
115
|
this.peerManager.on("peerConnected", (nodeId) => {
|
|
78
116
|
this.logger.info(`[clawmatrix] Peer connected: ${nodeId}`);
|
|
117
|
+
this.refreshDiscoveredModels();
|
|
79
118
|
});
|
|
80
119
|
|
|
81
120
|
this.peerManager.on("peerDisconnected", (nodeId) => {
|
|
82
121
|
this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
|
|
122
|
+
this.refreshDiscoveredModels();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this.peerManager.on("peerCapabilitiesChanged", () => {
|
|
126
|
+
this.refreshDiscoveredModels();
|
|
83
127
|
});
|
|
84
128
|
|
|
85
129
|
// Web dashboard (must be set before peerManager.start() creates the HTTP server)
|
|
@@ -98,8 +142,9 @@ export class ClusterRuntime {
|
|
|
98
142
|
const stateDir = path.join(workspacePath, ".clawmatrix");
|
|
99
143
|
this.knowledgeSync = new KnowledgeSync({
|
|
100
144
|
workspacePath,
|
|
101
|
-
|
|
145
|
+
stateDir,
|
|
102
146
|
nodeId: this.config.nodeId,
|
|
147
|
+
paths: this.config.knowledge.paths ?? [],
|
|
103
148
|
debounce: this.config.knowledge.debounce ?? 5000,
|
|
104
149
|
maxFileSize: this.config.knowledge.maxFileSize ?? 512 * 1024,
|
|
105
150
|
peerManager: this.peerManager,
|
|
@@ -124,6 +169,22 @@ export class ClusterRuntime {
|
|
|
124
169
|
this.peerManager.start();
|
|
125
170
|
this.modelProxy.start();
|
|
126
171
|
|
|
172
|
+
// Sentinel: detached subprocess for diagnostics when gateway dies.
|
|
173
|
+
// Default on; starts when not explicitly disabled AND (has outbound peers OR gateway is a listener for port takeover)
|
|
174
|
+
if ((this.config.sentinel?.enabled ?? true) && (this.config.peers.length > 0 || this.config.listen)) {
|
|
175
|
+
this.sentinelManager = new SentinelManager(this.config);
|
|
176
|
+
this.sentinelManager.start();
|
|
177
|
+
this.logger.info(`[clawmatrix] Sentinel started for node "${this.config.nodeId}"`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Register process exit handlers for emergency cleanup (kill spawned ACP processes, close WS)
|
|
181
|
+
this.exitHandler = () => {
|
|
182
|
+
this.acpProxy?.destroy();
|
|
183
|
+
this.terminalManager.destroy();
|
|
184
|
+
this.handoffManager.destroy();
|
|
185
|
+
};
|
|
186
|
+
process.on("exit", this.exitHandler);
|
|
187
|
+
|
|
127
188
|
this.logger.info(
|
|
128
189
|
`[clawmatrix] Node "${this.config.nodeId}" started` +
|
|
129
190
|
(this.config.listen ? ` (listening on port ${this.config.listenPort})` : "") +
|
|
@@ -132,15 +193,30 @@ export class ClusterRuntime {
|
|
|
132
193
|
}
|
|
133
194
|
|
|
134
195
|
async stop() {
|
|
196
|
+
// Unregister exit handler since we're doing a clean shutdown
|
|
197
|
+
if (this.exitHandler) {
|
|
198
|
+
process.removeListener("exit", this.exitHandler);
|
|
199
|
+
this.exitHandler = null;
|
|
200
|
+
}
|
|
201
|
+
// NOTE: intentionally do NOT stop sentinel here.
|
|
202
|
+
// Sentinel must survive gateway shutdown — that's its entire purpose.
|
|
203
|
+
// It will be replaced by killOldSentinel() on next gateway start.
|
|
135
204
|
await this.knowledgeSync?.stop();
|
|
136
205
|
this.webHandler?.destroy();
|
|
137
206
|
this.handoffManager.destroy();
|
|
207
|
+
this.acpProxy?.destroy();
|
|
208
|
+
this.terminalManager.destroy();
|
|
138
209
|
this.modelProxy.stop();
|
|
139
210
|
this.toolProxy.destroy();
|
|
140
211
|
await this.peerManager.stop();
|
|
141
212
|
this.logger.info(`[clawmatrix] Node "${this.config.nodeId}" stopped`);
|
|
142
213
|
}
|
|
143
214
|
|
|
215
|
+
private refreshDiscoveredModels() {
|
|
216
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
217
|
+
this.modelProxy.updateDiscoveredModels(peers);
|
|
218
|
+
}
|
|
219
|
+
|
|
144
220
|
private resolveWorkspacePath(): string | null {
|
|
145
221
|
// Read workspace from OpenClaw agent config (first agent or default agent)
|
|
146
222
|
const agents = (this.openclawConfig as Record<string, unknown>).agents as
|
|
@@ -160,7 +236,7 @@ export class ClusterRuntime {
|
|
|
160
236
|
}
|
|
161
237
|
|
|
162
238
|
private dispatchFrame(frame: AnyClusterFrame) {
|
|
163
|
-
if (frame.type.startsWith("model_")) {
|
|
239
|
+
if (frame.type.startsWith("model_") || frame.type.startsWith("acp_")) {
|
|
164
240
|
debug("dispatch", `${frame.type} id=${frame.id} from=${frame.from}`);
|
|
165
241
|
}
|
|
166
242
|
switch (frame.type) {
|
|
@@ -212,6 +288,14 @@ export class ClusterRuntime {
|
|
|
212
288
|
case "tool_res":
|
|
213
289
|
this.toolProxy.handleResponse(frame as ToolProxyResponse);
|
|
214
290
|
break;
|
|
291
|
+
case "tool_batch_req":
|
|
292
|
+
this.toolProxy.handleBatchRequest(frame as ToolBatchRequest).catch((err) => {
|
|
293
|
+
this.logger.error(`[clawmatrix] Tool batch request error: ${err}`);
|
|
294
|
+
});
|
|
295
|
+
break;
|
|
296
|
+
case "tool_batch_res":
|
|
297
|
+
this.toolProxy.handleBatchResponse(frame as ToolBatchResponse);
|
|
298
|
+
break;
|
|
215
299
|
case "send":
|
|
216
300
|
this.handleSendMessage(frame as SendMessage);
|
|
217
301
|
break;
|
|
@@ -220,6 +304,162 @@ export class ClusterRuntime {
|
|
|
220
304
|
this.logger.error(`[clawmatrix] Knowledge sync error: ${err}`);
|
|
221
305
|
});
|
|
222
306
|
break;
|
|
307
|
+
case "acp_req":
|
|
308
|
+
if (this.acpProxy) {
|
|
309
|
+
this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
|
|
310
|
+
this.logger.error(`[clawmatrix] ACP request error: ${err}`);
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
const af = frame as AcpTaskRequest;
|
|
314
|
+
this.peerManager.sendTo(af.from, {
|
|
315
|
+
type: "acp_res", id: af.id, from: this.config.nodeId, to: af.from,
|
|
316
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
|
|
317
|
+
} as AcpTaskResponse);
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
case "acp_stream":
|
|
321
|
+
this.acpProxy?.handleStream(frame as AcpStreamChunk);
|
|
322
|
+
break;
|
|
323
|
+
case "acp_res":
|
|
324
|
+
this.acpProxy?.handleResponse(frame as AcpTaskResponse);
|
|
325
|
+
break;
|
|
326
|
+
case "acp_close":
|
|
327
|
+
if (this.acpProxy) {
|
|
328
|
+
this.acpProxy.handleClose(frame as AcpCloseRequest).catch((err) => {
|
|
329
|
+
this.logger.error(`[clawmatrix] ACP close error: ${err}`);
|
|
330
|
+
});
|
|
331
|
+
} else {
|
|
332
|
+
const cf = frame as AcpCloseRequest;
|
|
333
|
+
this.peerManager.sendTo(cf.from, {
|
|
334
|
+
type: "acp_close_res", id: cf.id, from: this.config.nodeId, to: cf.from,
|
|
335
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
|
|
336
|
+
} as AcpCloseResponse);
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
case "acp_close_res":
|
|
340
|
+
this.acpProxy?.handleCloseResponse(frame as AcpCloseResponse);
|
|
341
|
+
break;
|
|
342
|
+
case "acp_list_req":
|
|
343
|
+
if (this.acpProxy) {
|
|
344
|
+
this.acpProxy.handleListRequest(frame as AcpListRequest).catch((err) => {
|
|
345
|
+
this.logger.error(`[clawmatrix] ACP list error: ${err}`);
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
// ACP not enabled — still read session stores from disk (read-only discovery)
|
|
349
|
+
const lf = frame as AcpListRequest;
|
|
350
|
+
readAllSessionStoresFromDisk().then((allSessions) => {
|
|
351
|
+
const filtered = lf.payload.agent
|
|
352
|
+
? allSessions.filter((s) => s.agent === lf.payload.agent)
|
|
353
|
+
: allSessions;
|
|
354
|
+
this.peerManager.sendTo(lf.from, {
|
|
355
|
+
type: "acp_list_res", id: lf.id, from: this.config.nodeId, to: lf.from,
|
|
356
|
+
timestamp: Date.now(), payload: { success: true, sessions: filtered },
|
|
357
|
+
} as AcpListResponse);
|
|
358
|
+
}).catch(() => {
|
|
359
|
+
this.peerManager.sendTo(lf.from, {
|
|
360
|
+
type: "acp_list_res", id: lf.id, from: this.config.nodeId, to: lf.from,
|
|
361
|
+
timestamp: Date.now(), payload: { success: true, sessions: [] },
|
|
362
|
+
} as AcpListResponse);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
case "acp_list_res":
|
|
367
|
+
this.acpProxy?.handleListResponse(frame as AcpListResponse);
|
|
368
|
+
break;
|
|
369
|
+
case "acp_resume_req":
|
|
370
|
+
if (this.acpProxy) {
|
|
371
|
+
this.acpProxy.handleResumeRequest(frame as AcpResumeRequest).catch((err) => {
|
|
372
|
+
this.logger.error(`[clawmatrix] ACP resume error: ${err}`);
|
|
373
|
+
});
|
|
374
|
+
} else {
|
|
375
|
+
const rf = frame as AcpResumeRequest;
|
|
376
|
+
this.peerManager.sendTo(rf.from, {
|
|
377
|
+
type: "acp_resume_res", id: rf.id, from: this.config.nodeId, to: rf.from,
|
|
378
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
|
|
379
|
+
} as AcpResumeResponse);
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
case "acp_resume_res":
|
|
383
|
+
this.acpProxy?.handleResumeResponse(frame as AcpResumeResponse);
|
|
384
|
+
break;
|
|
385
|
+
case "acp_cancel":
|
|
386
|
+
if (this.acpProxy) {
|
|
387
|
+
this.acpProxy.handleCancelRequest(frame as AcpCancelRequest).catch((err) => {
|
|
388
|
+
this.logger.error(`[clawmatrix] ACP cancel error: ${err}`);
|
|
389
|
+
});
|
|
390
|
+
} else {
|
|
391
|
+
const cf = frame as AcpCancelRequest;
|
|
392
|
+
this.peerManager.sendTo(cf.from, {
|
|
393
|
+
type: "acp_cancel_res", id: cf.id, from: this.config.nodeId, to: cf.from,
|
|
394
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
|
|
395
|
+
} as AcpCancelResponse);
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
case "acp_cancel_res":
|
|
399
|
+
this.acpProxy?.handleCancelResponse(frame as AcpCancelResponse);
|
|
400
|
+
break;
|
|
401
|
+
case "acp_set_mode":
|
|
402
|
+
if (this.acpProxy) {
|
|
403
|
+
this.acpProxy.handleSetModeRequest(frame as AcpSetModeRequest).catch((err) => {
|
|
404
|
+
this.logger.error(`[clawmatrix] ACP set mode error: ${err}`);
|
|
405
|
+
});
|
|
406
|
+
} else {
|
|
407
|
+
const mf = frame as AcpSetModeRequest;
|
|
408
|
+
this.peerManager.sendTo(mf.from, {
|
|
409
|
+
type: "acp_set_mode_res", id: mf.id, from: this.config.nodeId, to: mf.from,
|
|
410
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
|
|
411
|
+
} as AcpSetModeResponse);
|
|
412
|
+
}
|
|
413
|
+
break;
|
|
414
|
+
case "acp_set_mode_res":
|
|
415
|
+
this.acpProxy?.handleSetModeResponse(frame as AcpSetModeResponse);
|
|
416
|
+
break;
|
|
417
|
+
case "acp_get_modes":
|
|
418
|
+
if (this.acpProxy) {
|
|
419
|
+
this.acpProxy.handleGetModesRequest(frame as AcpGetModesRequest).catch((err) => {
|
|
420
|
+
this.logger.error(`[clawmatrix] ACP get modes error: ${err}`);
|
|
421
|
+
});
|
|
422
|
+
} else {
|
|
423
|
+
const gf = frame as AcpGetModesRequest;
|
|
424
|
+
this.peerManager.sendTo(gf.from, {
|
|
425
|
+
type: "acp_get_modes_res", id: gf.id, from: this.config.nodeId, to: gf.from,
|
|
426
|
+
timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
|
|
427
|
+
} as AcpGetModesResponse);
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
case "acp_get_modes_res":
|
|
431
|
+
this.acpProxy?.handleGetModesResponse(frame as AcpGetModesResponse);
|
|
432
|
+
break;
|
|
433
|
+
case "chat_history_req":
|
|
434
|
+
if (this.acpProxy) {
|
|
435
|
+
this.acpProxy.handleChatHistoryRequest(frame as ChatHistoryRequest).catch((err) => {
|
|
436
|
+
this.logger.error(`[clawmatrix] Chat history request error: ${err}`);
|
|
437
|
+
});
|
|
438
|
+
} else {
|
|
439
|
+
const hf = frame as ChatHistoryRequest;
|
|
440
|
+
this.peerManager.sendTo(hf.from, {
|
|
441
|
+
type: "chat_history_res", id: hf.id, from: this.config.nodeId, to: hf.from,
|
|
442
|
+
timestamp: Date.now(), payload: { success: true, messages: [] },
|
|
443
|
+
} as ChatHistoryResponse);
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
case "chat_history_res":
|
|
447
|
+
this.acpProxy?.handleChatHistoryResponse(frame as ChatHistoryResponse);
|
|
448
|
+
break;
|
|
449
|
+
case "task_activity":
|
|
450
|
+
// Task activity is a notification consumed by mobile clients directly.
|
|
451
|
+
// No server-side handling needed — relay is handled by PeerManager.
|
|
452
|
+
break;
|
|
453
|
+
case "terminal_open":
|
|
454
|
+
case "terminal_open_res":
|
|
455
|
+
case "terminal_data":
|
|
456
|
+
case "terminal_resize":
|
|
457
|
+
case "terminal_close":
|
|
458
|
+
case "terminal_close_res":
|
|
459
|
+
this.terminalManager.dispatchFrame(
|
|
460
|
+
frame as TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse,
|
|
461
|
+
);
|
|
462
|
+
break;
|
|
223
463
|
}
|
|
224
464
|
}
|
|
225
465
|
|
package/src/compat.ts
CHANGED
|
@@ -16,12 +16,12 @@ export interface SpawnResult {
|
|
|
16
16
|
/** Spawn a subprocess and collect stdout/stderr. */
|
|
17
17
|
export function spawnProcess(
|
|
18
18
|
cmd: string[],
|
|
19
|
-
opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" },
|
|
20
|
-
): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; kill: () => void } {
|
|
19
|
+
opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore"; stdin?: "pipe" | "ignore" },
|
|
20
|
+
): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; stdin: WritableStream | null; kill: () => void; pid: number | undefined } {
|
|
21
21
|
const child = cpSpawn(cmd[0]!, cmd.slice(1), {
|
|
22
22
|
cwd: opts?.cwd,
|
|
23
23
|
stdio: [
|
|
24
|
-
"ignore",
|
|
24
|
+
opts?.stdin === "pipe" ? "pipe" : "ignore",
|
|
25
25
|
opts?.stdout === "ignore" ? "ignore" : "pipe",
|
|
26
26
|
opts?.stderr === "ignore" ? "ignore" : "pipe",
|
|
27
27
|
],
|
|
@@ -46,11 +46,92 @@ export function spawnProcess(
|
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function nodeWritableToWeb(stream: import("node:stream").Writable | null): WritableStream | null {
|
|
50
|
+
if (!stream) return null;
|
|
51
|
+
return new WritableStream({
|
|
52
|
+
write(chunk: Uint8Array) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
stream.write(chunk, (err) => (err ? reject(err) : resolve()));
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
close() {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
stream.end(resolve);
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
abort() {
|
|
63
|
+
stream.destroy();
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
return {
|
|
50
69
|
exited,
|
|
51
70
|
stdout: nodeStreamToWeb(child.stdout),
|
|
52
71
|
stderr: nodeStreamToWeb(child.stderr),
|
|
72
|
+
stdin: nodeWritableToWeb(child.stdin),
|
|
53
73
|
kill: () => child.kill(),
|
|
74
|
+
pid: child.pid,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── PTY support (optional, requires node-pty) ────────────────────
|
|
79
|
+
|
|
80
|
+
export interface PtyHandle {
|
|
81
|
+
onData(cb: (data: string) => void): void;
|
|
82
|
+
onExit(cb: (info: { exitCode: number; signal?: number }) => void): void;
|
|
83
|
+
write(data: string): void;
|
|
84
|
+
resize(cols: number, rows: number): void;
|
|
85
|
+
kill(): void;
|
|
86
|
+
pid: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let ptyModule: {
|
|
90
|
+
spawn(
|
|
91
|
+
file: string,
|
|
92
|
+
args: string[],
|
|
93
|
+
opts: { name?: string; cols?: number; rows?: number; cwd?: string; env?: Record<string, string> },
|
|
94
|
+
): { onData: (cb: (data: string) => void) => { dispose: () => void }; onExit: (cb: (info: { exitCode: number; signal?: number }) => void) => { dispose: () => void }; write: (data: string) => void; resize: (cols: number, rows: number) => void; kill: () => void; pid: number };
|
|
95
|
+
} | null | undefined;
|
|
96
|
+
|
|
97
|
+
function loadPty() {
|
|
98
|
+
if (ptyModule !== undefined) return ptyModule;
|
|
99
|
+
try {
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
101
|
+
ptyModule = require("node-pty");
|
|
102
|
+
} catch {
|
|
103
|
+
ptyModule = null;
|
|
104
|
+
}
|
|
105
|
+
return ptyModule;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isPtyAvailable(): boolean {
|
|
109
|
+
return loadPty() !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function spawnPty(
|
|
113
|
+
shell: string,
|
|
114
|
+
args: string[],
|
|
115
|
+
opts: { cols?: number; rows?: number; cwd?: string; env?: Record<string, string> },
|
|
116
|
+
): PtyHandle {
|
|
117
|
+
const pty = loadPty();
|
|
118
|
+
if (!pty) throw new Error("node-pty is not available — install it with: npm install node-pty");
|
|
119
|
+
|
|
120
|
+
const proc = pty.spawn(shell, args, {
|
|
121
|
+
name: "xterm-256color",
|
|
122
|
+
cols: opts.cols ?? 80,
|
|
123
|
+
rows: opts.rows ?? 24,
|
|
124
|
+
cwd: opts.cwd,
|
|
125
|
+
env: opts.env ? { ...process.env, ...opts.env } as Record<string, string> : process.env as Record<string, string>,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
onData(cb) { proc.onData(cb); },
|
|
130
|
+
onExit(cb) { proc.onExit(cb); },
|
|
131
|
+
write(data) { proc.write(data); },
|
|
132
|
+
resize(cols, rows) { proc.resize(cols, rows); },
|
|
133
|
+
kill() { proc.kill(); },
|
|
134
|
+
pid: proc.pid,
|
|
54
135
|
};
|
|
55
136
|
}
|
|
56
137
|
|