agent-relay 3.1.0 → 3.1.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/bin/agent-relay-broker-linux-x64 +0 -0
- package/package.json +9 -9
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/README.md +78 -0
- package/packages/openclaw/bin/relay-openclaw.mjs +2 -0
- package/packages/openclaw/bridge/bridge.mjs +305 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js +320 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/naming.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/naming.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/naming.test.js +21 -0
- package/packages/openclaw/dist/__tests__/naming.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js +126 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -0
- package/packages/openclaw/dist/auth/converter.d.ts +28 -0
- package/packages/openclaw/dist/auth/converter.d.ts.map +1 -0
- package/packages/openclaw/dist/auth/converter.js +64 -0
- package/packages/openclaw/dist/auth/converter.js.map +1 -0
- package/packages/openclaw/dist/cli.d.ts +2 -0
- package/packages/openclaw/dist/cli.d.ts.map +1 -0
- package/packages/openclaw/dist/cli.js +230 -0
- package/packages/openclaw/dist/cli.js.map +1 -0
- package/packages/openclaw/dist/config.d.ts +27 -0
- package/packages/openclaw/dist/config.d.ts.map +1 -0
- package/packages/openclaw/dist/config.js +97 -0
- package/packages/openclaw/dist/config.js.map +1 -0
- package/packages/openclaw/dist/control.d.ts +22 -0
- package/packages/openclaw/dist/control.d.ts.map +1 -0
- package/packages/openclaw/dist/control.js +58 -0
- package/packages/openclaw/dist/control.js.map +1 -0
- package/packages/openclaw/dist/gateway.d.ts +71 -0
- package/packages/openclaw/dist/gateway.d.ts.map +1 -0
- package/packages/openclaw/dist/gateway.js +785 -0
- package/packages/openclaw/dist/gateway.js.map +1 -0
- package/packages/openclaw/dist/identity/contract.d.ts +11 -0
- package/packages/openclaw/dist/identity/contract.d.ts.map +1 -0
- package/packages/openclaw/dist/identity/contract.js +40 -0
- package/packages/openclaw/dist/identity/contract.js.map +1 -0
- package/packages/openclaw/dist/identity/files.d.ts +33 -0
- package/packages/openclaw/dist/identity/files.d.ts.map +1 -0
- package/packages/openclaw/dist/identity/files.js +145 -0
- package/packages/openclaw/dist/identity/files.js.map +1 -0
- package/packages/openclaw/dist/identity/model.d.ts +11 -0
- package/packages/openclaw/dist/identity/model.d.ts.map +1 -0
- package/packages/openclaw/dist/identity/model.js +28 -0
- package/packages/openclaw/dist/identity/model.js.map +1 -0
- package/packages/openclaw/dist/identity/naming.d.ts +5 -0
- package/packages/openclaw/dist/identity/naming.d.ts.map +1 -0
- package/packages/openclaw/dist/identity/naming.js +7 -0
- package/packages/openclaw/dist/identity/naming.js.map +1 -0
- package/packages/openclaw/dist/index.d.ts +20 -0
- package/packages/openclaw/dist/index.d.ts.map +1 -0
- package/packages/openclaw/dist/index.js +27 -0
- package/packages/openclaw/dist/index.js.map +1 -0
- package/packages/openclaw/dist/inject.d.ts +14 -0
- package/packages/openclaw/dist/inject.d.ts.map +1 -0
- package/packages/openclaw/dist/inject.js +66 -0
- package/packages/openclaw/dist/inject.js.map +1 -0
- package/packages/openclaw/dist/mcp/server.d.ts +8 -0
- package/packages/openclaw/dist/mcp/server.d.ts.map +1 -0
- package/packages/openclaw/dist/mcp/server.js +105 -0
- package/packages/openclaw/dist/mcp/server.js.map +1 -0
- package/packages/openclaw/dist/mcp/tools.d.ts +17 -0
- package/packages/openclaw/dist/mcp/tools.d.ts.map +1 -0
- package/packages/openclaw/dist/mcp/tools.js +145 -0
- package/packages/openclaw/dist/mcp/tools.js.map +1 -0
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts +20 -0
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -0
- package/packages/openclaw/dist/runtime/openclaw-config.js +50 -0
- package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -0
- package/packages/openclaw/dist/runtime/patch.d.ts +24 -0
- package/packages/openclaw/dist/runtime/patch.d.ts.map +1 -0
- package/packages/openclaw/dist/runtime/patch.js +92 -0
- package/packages/openclaw/dist/runtime/patch.js.map +1 -0
- package/packages/openclaw/dist/runtime/setup.d.ts +26 -0
- package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -0
- package/packages/openclaw/dist/runtime/setup.js +58 -0
- package/packages/openclaw/dist/runtime/setup.js.map +1 -0
- package/packages/openclaw/dist/setup.d.ts +29 -0
- package/packages/openclaw/dist/setup.d.ts.map +1 -0
- package/packages/openclaw/dist/setup.js +300 -0
- package/packages/openclaw/dist/setup.js.map +1 -0
- package/packages/openclaw/dist/spawn/docker.d.ts +58 -0
- package/packages/openclaw/dist/spawn/docker.d.ts.map +1 -0
- package/packages/openclaw/dist/spawn/docker.js +222 -0
- package/packages/openclaw/dist/spawn/docker.js.map +1 -0
- package/packages/openclaw/dist/spawn/manager.d.ts +45 -0
- package/packages/openclaw/dist/spawn/manager.d.ts.map +1 -0
- package/packages/openclaw/dist/spawn/manager.js +140 -0
- package/packages/openclaw/dist/spawn/manager.js.map +1 -0
- package/packages/openclaw/dist/spawn/process.d.ts +16 -0
- package/packages/openclaw/dist/spawn/process.d.ts.map +1 -0
- package/packages/openclaw/dist/spawn/process.js +241 -0
- package/packages/openclaw/dist/spawn/process.js.map +1 -0
- package/packages/openclaw/dist/spawn/types.d.ts +42 -0
- package/packages/openclaw/dist/spawn/types.d.ts.map +1 -0
- package/packages/openclaw/dist/spawn/types.js +2 -0
- package/packages/openclaw/dist/spawn/types.js.map +1 -0
- package/packages/openclaw/dist/types.d.ts +37 -0
- package/packages/openclaw/dist/types.d.ts.map +1 -0
- package/packages/openclaw/dist/types.js +2 -0
- package/packages/openclaw/dist/types.js.map +1 -0
- package/packages/openclaw/package.json +63 -0
- package/packages/openclaw/skill/SKILL.md +194 -0
- package/packages/openclaw/src/__tests__/gateway-threads.test.ts +384 -0
- package/packages/openclaw/src/__tests__/naming.test.ts +24 -0
- package/packages/openclaw/src/__tests__/spawn-manager.test.ts +152 -0
- package/packages/openclaw/src/auth/converter.ts +90 -0
- package/packages/openclaw/src/cli.ts +269 -0
- package/packages/openclaw/src/config.ts +124 -0
- package/packages/openclaw/src/control.ts +100 -0
- package/packages/openclaw/src/gateway.ts +941 -0
- package/packages/openclaw/src/identity/contract.ts +44 -0
- package/packages/openclaw/src/identity/files.ts +198 -0
- package/packages/openclaw/src/identity/model.ts +27 -0
- package/packages/openclaw/src/identity/naming.ts +6 -0
- package/packages/openclaw/src/index.ts +59 -0
- package/packages/openclaw/src/inject.ts +77 -0
- package/packages/openclaw/src/mcp/server.ts +121 -0
- package/packages/openclaw/src/mcp/tools.ts +174 -0
- package/packages/openclaw/src/runtime/openclaw-config.ts +64 -0
- package/packages/openclaw/src/runtime/patch.ts +103 -0
- package/packages/openclaw/src/runtime/setup.ts +89 -0
- package/packages/openclaw/src/setup.ts +336 -0
- package/packages/openclaw/src/spawn/docker.ts +261 -0
- package/packages/openclaw/src/spawn/manager.ts +181 -0
- package/packages/openclaw/src/spawn/process.ts +272 -0
- package/packages/openclaw/src/spawn/types.ts +43 -0
- package/packages/openclaw/src/types.ts +38 -0
- package/packages/openclaw/templates/SOUL.md.template +34 -0
- package/packages/openclaw/tsconfig.json +12 -0
- package/packages/policy/package.json +2 -2
- package/packages/sdk/package.json +2 -2
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
import { createHash, generateKeyPairSync, sign, type KeyObject } from 'node:crypto';
|
|
2
|
+
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
import type { SendMessageInput } from '@agent-relay/sdk';
|
|
5
|
+
import { RelayCast, type AgentClient } from '@relaycast/sdk';
|
|
6
|
+
import type { MessageCreatedEvent, MessageWithMeta, ThreadReplyEvent } from '@relaycast/sdk';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
|
|
9
|
+
import type { GatewayConfig, InboundMessage, DeliveryResult } from './types.js';
|
|
10
|
+
import { SpawnManager } from './spawn/manager.js';
|
|
11
|
+
import type { SpawnOptions } from './spawn/types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A minimal interface for sending messages via Agent Relay.
|
|
15
|
+
* Accepts either AgentRelayClient or AgentRelay — any object with a
|
|
16
|
+
* compatible sendMessage() method.
|
|
17
|
+
*/
|
|
18
|
+
export interface RelaySender {
|
|
19
|
+
sendMessage(input: SendMessageInput): Promise<{ event_id: string; targets?: string[] }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GatewayOptions {
|
|
23
|
+
/** Gateway configuration. */
|
|
24
|
+
config: GatewayConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Pre-existing relay sender for message delivery.
|
|
27
|
+
* Pass the API server's AgentRelay instance so all gateways share a single
|
|
28
|
+
* broker process instead of each spawning their own.
|
|
29
|
+
*/
|
|
30
|
+
relaySender?: RelaySender;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeChannelName(channel: string): string {
|
|
34
|
+
return channel.startsWith('#') ? channel.slice(1) : channel;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Ed25519 device identity for OpenClaw gateway WebSocket auth
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
interface DeviceIdentity {
|
|
42
|
+
publicKeyB64: string; // base64url-encoded raw Ed25519 public key
|
|
43
|
+
privateKeyObj: KeyObject; // Node.js KeyObject for signing
|
|
44
|
+
deviceId: string; // SHA-256 hex of the raw public key
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateDeviceIdentity(): DeviceIdentity {
|
|
48
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
49
|
+
|
|
50
|
+
// Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519)
|
|
51
|
+
const rawPublicBytes = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
|
|
52
|
+
|
|
53
|
+
const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex');
|
|
54
|
+
const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url');
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
publicKeyB64,
|
|
58
|
+
privateKeyObj: privateKey,
|
|
59
|
+
deviceId,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function signConnectPayload(
|
|
64
|
+
device: DeviceIdentity,
|
|
65
|
+
params: {
|
|
66
|
+
clientId: string;
|
|
67
|
+
clientMode: string;
|
|
68
|
+
platform: string;
|
|
69
|
+
deviceFamily: string;
|
|
70
|
+
role: string;
|
|
71
|
+
scopes: string[];
|
|
72
|
+
signedAt: number;
|
|
73
|
+
token: string;
|
|
74
|
+
nonce: string;
|
|
75
|
+
},
|
|
76
|
+
): string {
|
|
77
|
+
// v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
|
|
78
|
+
const payload = [
|
|
79
|
+
'v3',
|
|
80
|
+
device.deviceId,
|
|
81
|
+
params.clientId,
|
|
82
|
+
params.clientMode,
|
|
83
|
+
params.role,
|
|
84
|
+
params.scopes.join(','),
|
|
85
|
+
String(params.signedAt),
|
|
86
|
+
params.token || '',
|
|
87
|
+
params.nonce,
|
|
88
|
+
params.platform,
|
|
89
|
+
params.deviceFamily,
|
|
90
|
+
].join('|');
|
|
91
|
+
|
|
92
|
+
const payloadBytes = Buffer.from(payload, 'utf-8');
|
|
93
|
+
|
|
94
|
+
// Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519
|
|
95
|
+
const signature = sign(null, payloadBytes, device.privateKeyObj);
|
|
96
|
+
return Buffer.from(signature).toString('base64url');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Persistent OpenClaw Gateway WebSocket client
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
interface PendingRpc {
|
|
105
|
+
resolve: (value: boolean) => void;
|
|
106
|
+
timer: ReturnType<typeof setTimeout>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class OpenClawGatewayClient {
|
|
110
|
+
private ws: WebSocket | null = null;
|
|
111
|
+
private authenticated = false;
|
|
112
|
+
private device: DeviceIdentity;
|
|
113
|
+
private token: string;
|
|
114
|
+
private port: number;
|
|
115
|
+
private pendingRpcs = new Map<string, PendingRpc>();
|
|
116
|
+
private rpcIdCounter = 0;
|
|
117
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
private stopped = false;
|
|
119
|
+
private connectPromise: Promise<void> | null = null;
|
|
120
|
+
private connectResolve: (() => void) | null = null;
|
|
121
|
+
private connectReject: ((error: Error) => void) | null = null;
|
|
122
|
+
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
|
|
124
|
+
/** Default timeout for initial connection (30 seconds). */
|
|
125
|
+
private static readonly CONNECT_TIMEOUT_MS = 30_000;
|
|
126
|
+
|
|
127
|
+
constructor(token: string, port: number) {
|
|
128
|
+
this.token = token;
|
|
129
|
+
this.port = port;
|
|
130
|
+
this.device = generateDeviceIdentity();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Connect and authenticate. Resolves when chat.send is ready, rejects on timeout or error. */
|
|
134
|
+
async connect(): Promise<void> {
|
|
135
|
+
if (this.authenticated && this.ws?.readyState === WebSocket.OPEN) return;
|
|
136
|
+
|
|
137
|
+
// Cancel any pending reconnect timer to prevent orphaned WebSocket connections
|
|
138
|
+
if (this.reconnectTimer) {
|
|
139
|
+
clearTimeout(this.reconnectTimer);
|
|
140
|
+
this.reconnectTimer = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.connectPromise = new Promise<void>((resolve, reject) => {
|
|
144
|
+
this.connectResolve = resolve;
|
|
145
|
+
this.connectReject = reject;
|
|
146
|
+
|
|
147
|
+
// Set up timeout to prevent indefinite hanging
|
|
148
|
+
this.connectTimeout = setTimeout(() => {
|
|
149
|
+
this.connectTimeout = null;
|
|
150
|
+
if (!this.authenticated) {
|
|
151
|
+
const err = new Error(`Connection to OpenClaw gateway timed out after ${OpenClawGatewayClient.CONNECT_TIMEOUT_MS}ms`);
|
|
152
|
+
this.connectReject?.(err);
|
|
153
|
+
this.connectReject = null;
|
|
154
|
+
this.connectResolve = null;
|
|
155
|
+
}
|
|
156
|
+
}, OpenClawGatewayClient.CONNECT_TIMEOUT_MS);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.doConnect();
|
|
160
|
+
return this.connectPromise;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private clearConnectTimeout(): void {
|
|
164
|
+
if (this.connectTimeout) {
|
|
165
|
+
clearTimeout(this.connectTimeout);
|
|
166
|
+
this.connectTimeout = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private doConnect(): void {
|
|
171
|
+
if (this.stopped) return;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
+
this.scheduleReconnect();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.ws.on('open', () => {
|
|
182
|
+
console.log('[openclaw-ws] Connected to OpenClaw gateway');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
this.ws.on('message', (data) => {
|
|
186
|
+
this.handleMessage(data.toString());
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
this.ws.on('close', (code, reason) => {
|
|
190
|
+
console.warn(`[openclaw-ws] Disconnected: ${code} ${reason.toString()}`);
|
|
191
|
+
const wasAuthenticated = this.authenticated;
|
|
192
|
+
this.authenticated = false;
|
|
193
|
+
// Reject all pending RPCs
|
|
194
|
+
for (const [id, pending] of this.pendingRpcs) {
|
|
195
|
+
clearTimeout(pending.timer);
|
|
196
|
+
pending.resolve(false);
|
|
197
|
+
this.pendingRpcs.delete(id);
|
|
198
|
+
}
|
|
199
|
+
// If we weren't authenticated yet, reject the connect promise
|
|
200
|
+
if (!wasAuthenticated && this.connectReject) {
|
|
201
|
+
this.clearConnectTimeout();
|
|
202
|
+
const err = new Error(`WebSocket closed before authentication (code=${code})`);
|
|
203
|
+
this.connectReject(err);
|
|
204
|
+
this.connectReject = null;
|
|
205
|
+
this.connectResolve = null;
|
|
206
|
+
}
|
|
207
|
+
if (!this.stopped) {
|
|
208
|
+
this.scheduleReconnect();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
this.ws.on('error', (err) => {
|
|
213
|
+
console.warn(`[openclaw-ws] Error: ${err.message}`);
|
|
214
|
+
// If we weren't authenticated yet, reject the connect promise
|
|
215
|
+
if (!this.authenticated && this.connectReject) {
|
|
216
|
+
this.clearConnectTimeout();
|
|
217
|
+
this.connectReject(err);
|
|
218
|
+
this.connectReject = null;
|
|
219
|
+
this.connectResolve = null;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private handleMessage(raw: string): void {
|
|
225
|
+
let msg: Record<string, unknown>;
|
|
226
|
+
try {
|
|
227
|
+
msg = JSON.parse(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Handle connect.challenge — sign and respond
|
|
233
|
+
if (msg.type === 'event' && msg.event === 'connect.challenge') {
|
|
234
|
+
const payload = msg.payload as { nonce: string; ts: number };
|
|
235
|
+
console.log('[openclaw-ws] Received connect.challenge, signing...');
|
|
236
|
+
|
|
237
|
+
const signedAt = Date.now();
|
|
238
|
+
const clientId = 'cli';
|
|
239
|
+
const clientMode = 'cli';
|
|
240
|
+
const platform = process.platform === 'darwin' ? 'macos' : 'linux';
|
|
241
|
+
const deviceFamily = 'cli';
|
|
242
|
+
const role = 'operator';
|
|
243
|
+
const scopes = ['operator.read', 'operator.write'];
|
|
244
|
+
|
|
245
|
+
const signature = signConnectPayload(this.device, {
|
|
246
|
+
clientId,
|
|
247
|
+
clientMode,
|
|
248
|
+
platform,
|
|
249
|
+
deviceFamily,
|
|
250
|
+
role,
|
|
251
|
+
scopes,
|
|
252
|
+
signedAt,
|
|
253
|
+
token: this.token,
|
|
254
|
+
nonce: payload.nonce,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
258
|
+
console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.ws.send(JSON.stringify({
|
|
262
|
+
type: 'req',
|
|
263
|
+
id: 'connect-1',
|
|
264
|
+
method: 'connect',
|
|
265
|
+
params: {
|
|
266
|
+
minProtocol: 3,
|
|
267
|
+
maxProtocol: 3,
|
|
268
|
+
client: {
|
|
269
|
+
id: clientId,
|
|
270
|
+
version: '1.0.0',
|
|
271
|
+
platform,
|
|
272
|
+
mode: clientMode,
|
|
273
|
+
deviceFamily,
|
|
274
|
+
},
|
|
275
|
+
role,
|
|
276
|
+
scopes,
|
|
277
|
+
caps: [],
|
|
278
|
+
commands: [],
|
|
279
|
+
permissions: {},
|
|
280
|
+
auth: { token: this.token },
|
|
281
|
+
locale: 'en-US',
|
|
282
|
+
userAgent: 'relaycast-gateway/1.0.0',
|
|
283
|
+
device: {
|
|
284
|
+
id: this.device.deviceId,
|
|
285
|
+
publicKey: this.device.publicKeyB64,
|
|
286
|
+
signature,
|
|
287
|
+
signedAt,
|
|
288
|
+
nonce: payload.nonce,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
}));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Handle connect response
|
|
296
|
+
if (msg.type === 'res' && msg.id === 'connect-1') {
|
|
297
|
+
this.clearConnectTimeout();
|
|
298
|
+
if (msg.ok) {
|
|
299
|
+
console.log('[openclaw-ws] Authenticated successfully');
|
|
300
|
+
this.authenticated = true;
|
|
301
|
+
this.connectResolve?.();
|
|
302
|
+
this.connectResolve = null;
|
|
303
|
+
this.connectReject = null;
|
|
304
|
+
} else {
|
|
305
|
+
console.warn(`[openclaw-ws] Auth rejected: ${JSON.stringify(msg.error ?? msg)}`);
|
|
306
|
+
// Reject the connect promise on auth failure
|
|
307
|
+
const errMsg = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected';
|
|
308
|
+
this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errMsg}`));
|
|
309
|
+
this.connectReject = null;
|
|
310
|
+
this.connectResolve = null;
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Handle RPC responses
|
|
316
|
+
const id = msg.id as string | undefined;
|
|
317
|
+
if (id && this.pendingRpcs.has(id)) {
|
|
318
|
+
const pending = this.pendingRpcs.get(id)!;
|
|
319
|
+
clearTimeout(pending.timer);
|
|
320
|
+
this.pendingRpcs.delete(id);
|
|
321
|
+
|
|
322
|
+
if (msg.ok === false || msg.error) {
|
|
323
|
+
console.warn(`[openclaw-ws] RPC ${id} error: ${JSON.stringify(msg.error ?? msg)}`);
|
|
324
|
+
pending.resolve(false);
|
|
325
|
+
} else {
|
|
326
|
+
const result = msg.payload as Record<string, unknown> | undefined;
|
|
327
|
+
console.log(`[openclaw-ws] RPC ${id} ok: runId=${result?.runId ?? 'n/a'} status=${result?.status ?? 'n/a'}`);
|
|
328
|
+
pending.resolve(true);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Log other events at debug level
|
|
334
|
+
if (msg.type === 'event') {
|
|
335
|
+
// chat events, tick events, etc. — ignore silently
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Send a chat.send RPC. Returns true if accepted. */
|
|
340
|
+
async sendChatMessage(text: string, idempotencyKey?: string): Promise<boolean> {
|
|
341
|
+
if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
342
|
+
// Try to reconnect
|
|
343
|
+
try {
|
|
344
|
+
await this.connect();
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
if (!this.authenticated) return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const id = `chat-${++this.rpcIdCounter}-${Date.now()}`;
|
|
352
|
+
|
|
353
|
+
return new Promise<boolean>((resolve) => {
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
console.warn(`[openclaw-ws] chat.send ${id} timed out`);
|
|
356
|
+
this.pendingRpcs.delete(id);
|
|
357
|
+
resolve(false);
|
|
358
|
+
}, 15_000);
|
|
359
|
+
|
|
360
|
+
this.pendingRpcs.set(id, { resolve, timer });
|
|
361
|
+
|
|
362
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
this.pendingRpcs.delete(id);
|
|
365
|
+
resolve(false);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.ws.send(JSON.stringify({
|
|
369
|
+
type: 'req',
|
|
370
|
+
id,
|
|
371
|
+
method: 'chat.send',
|
|
372
|
+
params: {
|
|
373
|
+
sessionKey: 'agent:main:main',
|
|
374
|
+
message: text,
|
|
375
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
376
|
+
},
|
|
377
|
+
}));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private scheduleReconnect(): void {
|
|
382
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
383
|
+
console.log('[openclaw-ws] Reconnecting in 3s...');
|
|
384
|
+
this.reconnectTimer = setTimeout(() => {
|
|
385
|
+
this.reconnectTimer = null;
|
|
386
|
+
this.doConnect();
|
|
387
|
+
}, 3_000);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async disconnect(): Promise<void> {
|
|
391
|
+
this.stopped = true;
|
|
392
|
+
this.clearConnectTimeout();
|
|
393
|
+
if (this.reconnectTimer) {
|
|
394
|
+
clearTimeout(this.reconnectTimer);
|
|
395
|
+
this.reconnectTimer = null;
|
|
396
|
+
}
|
|
397
|
+
for (const [id, pending] of this.pendingRpcs) {
|
|
398
|
+
clearTimeout(pending.timer);
|
|
399
|
+
pending.resolve(false);
|
|
400
|
+
this.pendingRpcs.delete(id);
|
|
401
|
+
}
|
|
402
|
+
if (this.ws) {
|
|
403
|
+
try { this.ws.close(); } catch {}
|
|
404
|
+
this.ws = null;
|
|
405
|
+
}
|
|
406
|
+
this.authenticated = false;
|
|
407
|
+
// Clear any pending connect promise
|
|
408
|
+
this.connectReject = null;
|
|
409
|
+
this.connectResolve = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// InboundGateway
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
export class InboundGateway {
|
|
418
|
+
private readonly relaySender: RelaySender | null;
|
|
419
|
+
private relayAgentClient: AgentClient | null = null;
|
|
420
|
+
private readonly relaycast: RelayCast;
|
|
421
|
+
private readonly config: GatewayConfig;
|
|
422
|
+
private readonly fallbackPollMs: number;
|
|
423
|
+
private readonly dedupeTtlMs: number;
|
|
424
|
+
|
|
425
|
+
private running = false;
|
|
426
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
427
|
+
private unsubscribeHandlers: Array<() => void> = [];
|
|
428
|
+
private seenMessageIds = new Map<string, number>();
|
|
429
|
+
private processingMessageIds = new Set<string>();
|
|
430
|
+
private channelCursor = new Map<string, string>();
|
|
431
|
+
|
|
432
|
+
/** Persistent WebSocket client for the local OpenClaw gateway. */
|
|
433
|
+
private openclawClient: OpenClawGatewayClient | null = null;
|
|
434
|
+
|
|
435
|
+
/** Spawn manager — lives in the gateway so spawned processes survive MCP server restarts. */
|
|
436
|
+
private spawnManager: SpawnManager;
|
|
437
|
+
/** HTTP control server for spawn/list/release commands. */
|
|
438
|
+
private controlServer: HttpServer | null = null;
|
|
439
|
+
/** Port the control server listens on. */
|
|
440
|
+
controlPort = 0;
|
|
441
|
+
|
|
442
|
+
/** Default control port for the gateway's spawn API. */
|
|
443
|
+
static readonly DEFAULT_CONTROL_PORT = 18790;
|
|
444
|
+
|
|
445
|
+
constructor(options: GatewayOptions) {
|
|
446
|
+
this.config = {
|
|
447
|
+
...options.config,
|
|
448
|
+
channels: options.config.channels.map(normalizeChannelName),
|
|
449
|
+
};
|
|
450
|
+
this.relaySender = options.relaySender ?? null;
|
|
451
|
+
this.relaycast = new RelayCast({
|
|
452
|
+
apiKey: this.config.apiKey,
|
|
453
|
+
baseUrl: this.config.baseUrl,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const fallbackPollMs = Number(process.env.RELAYCAST_FALLBACK_POLL_MS ?? 15000);
|
|
457
|
+
this.fallbackPollMs = Number.isFinite(fallbackPollMs) && fallbackPollMs >= 1000
|
|
458
|
+
? Math.floor(fallbackPollMs)
|
|
459
|
+
: 15000;
|
|
460
|
+
|
|
461
|
+
const dedupeTtlMs = Number(process.env.RELAYCAST_DEDUPE_TTL_MS ?? 15 * 60 * 1000);
|
|
462
|
+
this.dedupeTtlMs = Number.isFinite(dedupeTtlMs) && dedupeTtlMs >= 1000
|
|
463
|
+
? Math.floor(dedupeTtlMs)
|
|
464
|
+
: 15 * 60 * 1000;
|
|
465
|
+
|
|
466
|
+
const parentDepth = Number(process.env.OPENCLAW_SPAWN_DEPTH || 0);
|
|
467
|
+
this.spawnManager = new SpawnManager({ spawnDepth: parentDepth + 1 });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Start the gateway — register agent, subscribe for realtime events, and run fallback polling. */
|
|
471
|
+
async start(): Promise<void> {
|
|
472
|
+
if (this.running) return;
|
|
473
|
+
this.running = true;
|
|
474
|
+
|
|
475
|
+
// Connect to the local OpenClaw gateway WebSocket (persistent connection)
|
|
476
|
+
const token = this.config.openclawGatewayToken ?? process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
477
|
+
const port = this.config.openclawGatewayPort ?? 18789;
|
|
478
|
+
|
|
479
|
+
if (token) {
|
|
480
|
+
this.openclawClient = new OpenClawGatewayClient(token, port);
|
|
481
|
+
try {
|
|
482
|
+
await this.openclawClient.connect();
|
|
483
|
+
console.log('[gateway] OpenClaw gateway WebSocket client ready');
|
|
484
|
+
} catch (err) {
|
|
485
|
+
console.warn(`[gateway] OpenClaw gateway WS failed (will retry per message): ${err instanceof Error ? err.message : String(err)}`);
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
console.warn('[gateway] No OPENCLAW_GATEWAY_TOKEN — local delivery disabled');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Register with a viewer- prefixed name so we don't collide with the
|
|
492
|
+
// container broker's agent registration (which uses the bare clawName).
|
|
493
|
+
const viewerName = `viewer-${this.config.clawName}`;
|
|
494
|
+
const registered = await this.relaycast.agents.registerOrGet({
|
|
495
|
+
name: viewerName,
|
|
496
|
+
type: 'agent',
|
|
497
|
+
persona: 'Relaycast inbound gateway for OpenClaw',
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
this.relayAgentClient = this.relaycast.as(registered.token);
|
|
501
|
+
|
|
502
|
+
// Connect first, then register handlers. The SDK requires connect()
|
|
503
|
+
// before subscribe() can be called.
|
|
504
|
+
this.relayAgentClient.connect();
|
|
505
|
+
|
|
506
|
+
this.unsubscribeHandlers.push(
|
|
507
|
+
this.relayAgentClient.on.connected(() => {
|
|
508
|
+
console.log(`[gateway] Relaycast WebSocket connected, subscribing to channels: ${this.config.channels.join(', ')}`);
|
|
509
|
+
this.relayAgentClient?.subscribe(this.config.channels);
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
this.unsubscribeHandlers.push(
|
|
513
|
+
this.relayAgentClient.on.messageCreated((event: MessageCreatedEvent) => {
|
|
514
|
+
console.log(`[gateway] Realtime message from @${event.message?.agentName} in #${event.channel}`);
|
|
515
|
+
void this.handleRealtimeMessage(event);
|
|
516
|
+
}),
|
|
517
|
+
);
|
|
518
|
+
this.unsubscribeHandlers.push(
|
|
519
|
+
this.relayAgentClient.on.threadReply((event: ThreadReplyEvent) => {
|
|
520
|
+
console.log(`[gateway] Thread reply from @${event.message?.agentName} in #${event.channel} (parent: ${event.parentId})`);
|
|
521
|
+
void this.handleRealtimeThreadReply(event);
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
this.unsubscribeHandlers.push(
|
|
525
|
+
this.relayAgentClient.on.reconnecting((attempt: number) => {
|
|
526
|
+
console.warn(`[gateway] Relaycast reconnecting (attempt ${attempt})`);
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
this.unsubscribeHandlers.push(
|
|
530
|
+
this.relayAgentClient.on.disconnected(() => {
|
|
531
|
+
console.warn(`[gateway] Relaycast disconnected`);
|
|
532
|
+
}),
|
|
533
|
+
);
|
|
534
|
+
this.unsubscribeHandlers.push(
|
|
535
|
+
this.relayAgentClient.on.error(() => {
|
|
536
|
+
console.warn(`[gateway] Relaycast socket error`);
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
await this.ensureChannelMembership();
|
|
541
|
+
|
|
542
|
+
// Also subscribe explicitly in case the `connected` event already fired
|
|
543
|
+
// before we registered the handler above.
|
|
544
|
+
try {
|
|
545
|
+
this.relayAgentClient.subscribe(this.config.channels);
|
|
546
|
+
} catch {
|
|
547
|
+
// Will subscribe on next connected event
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Initial catch-up in case messages arrived before realtime subscription was active.
|
|
551
|
+
await this.pollMessages();
|
|
552
|
+
|
|
553
|
+
// Keep a low-frequency poll as recovery/backfill only.
|
|
554
|
+
this.pollTimer = setInterval(() => {
|
|
555
|
+
void this.pollMessages();
|
|
556
|
+
}, this.fallbackPollMs);
|
|
557
|
+
|
|
558
|
+
console.log(
|
|
559
|
+
`[gateway] Realtime listening on channels: ${this.config.channels.join(', ')}`,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// Start spawn control HTTP server
|
|
563
|
+
await this.startControlServer();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Stop the gateway — clean up websocket and relay clients. */
|
|
567
|
+
async stop(): Promise<void> {
|
|
568
|
+
this.running = false;
|
|
569
|
+
|
|
570
|
+
if (this.pollTimer) {
|
|
571
|
+
clearInterval(this.pollTimer);
|
|
572
|
+
this.pollTimer = null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const unsubscribe of this.unsubscribeHandlers) {
|
|
576
|
+
try {
|
|
577
|
+
unsubscribe();
|
|
578
|
+
} catch {
|
|
579
|
+
// Best effort
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.unsubscribeHandlers = [];
|
|
583
|
+
|
|
584
|
+
if (this.relayAgentClient) {
|
|
585
|
+
try {
|
|
586
|
+
await this.relayAgentClient.disconnect();
|
|
587
|
+
} catch {
|
|
588
|
+
// Best effort
|
|
589
|
+
}
|
|
590
|
+
this.relayAgentClient = null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (this.openclawClient) {
|
|
594
|
+
await this.openclawClient.disconnect();
|
|
595
|
+
this.openclawClient = null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Stop control server and release all spawns
|
|
599
|
+
if (this.controlServer) {
|
|
600
|
+
this.controlServer.close();
|
|
601
|
+
this.controlServer = null;
|
|
602
|
+
}
|
|
603
|
+
await this.spawnManager.releaseAll();
|
|
604
|
+
|
|
605
|
+
this.processingMessageIds.clear();
|
|
606
|
+
this.channelCursor.clear();
|
|
607
|
+
this.seenMessageIds.clear();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private cleanupSeenMap(nowMs: number): void {
|
|
611
|
+
for (const [id, seenAt] of this.seenMessageIds.entries()) {
|
|
612
|
+
if (nowMs - seenAt > this.dedupeTtlMs) {
|
|
613
|
+
this.seenMessageIds.delete(id);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private isSeen(messageId: string): boolean {
|
|
619
|
+
const nowMs = Date.now();
|
|
620
|
+
this.cleanupSeenMap(nowMs);
|
|
621
|
+
return this.seenMessageIds.has(messageId);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private markSeen(messageId: string): void {
|
|
625
|
+
const nowMs = Date.now();
|
|
626
|
+
this.cleanupSeenMap(nowMs);
|
|
627
|
+
this.seenMessageIds.set(messageId, nowMs);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private async ensureChannelMembership(): Promise<void> {
|
|
631
|
+
if (!this.relayAgentClient) return;
|
|
632
|
+
|
|
633
|
+
for (const channel of this.config.channels) {
|
|
634
|
+
try {
|
|
635
|
+
await this.relayAgentClient.channels.join(channel);
|
|
636
|
+
} catch {
|
|
637
|
+
try {
|
|
638
|
+
await this.relayAgentClient.channels.create({ name: channel });
|
|
639
|
+
await this.relayAgentClient.channels.join(channel);
|
|
640
|
+
} catch {
|
|
641
|
+
// Non-fatal
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private async handleRealtimeMessage(event: MessageCreatedEvent): Promise<void> {
|
|
648
|
+
const channel = normalizeChannelName(event.channel);
|
|
649
|
+
if (!this.config.channels.includes(channel)) return;
|
|
650
|
+
|
|
651
|
+
const messageId = event.message?.id;
|
|
652
|
+
if (!messageId) return;
|
|
653
|
+
|
|
654
|
+
const inbound: InboundMessage = {
|
|
655
|
+
id: messageId,
|
|
656
|
+
channel,
|
|
657
|
+
from: event.message.agentName,
|
|
658
|
+
text: event.message.text,
|
|
659
|
+
timestamp: new Date().toISOString(),
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
await this.handleInbound(inbound);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private async handleRealtimeThreadReply(event: ThreadReplyEvent): Promise<void> {
|
|
666
|
+
const channel = normalizeChannelName(event.channel);
|
|
667
|
+
if (!this.config.channels.includes(channel)) return;
|
|
668
|
+
|
|
669
|
+
const messageId = event.message?.id;
|
|
670
|
+
if (!messageId) return;
|
|
671
|
+
|
|
672
|
+
const inbound: InboundMessage = {
|
|
673
|
+
id: messageId,
|
|
674
|
+
channel,
|
|
675
|
+
from: event.message.agentName,
|
|
676
|
+
text: event.message.text,
|
|
677
|
+
timestamp: new Date().toISOString(),
|
|
678
|
+
threadParentId: event.parentId,
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
await this.handleInbound(inbound);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private normalizePolledMessage(channel: string, message: MessageWithMeta): InboundMessage {
|
|
685
|
+
return {
|
|
686
|
+
id: message.id,
|
|
687
|
+
channel,
|
|
688
|
+
from: message.agentName,
|
|
689
|
+
text: message.text,
|
|
690
|
+
timestamp: message.createdAt,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Poll channels for catch-up/recovery only. */
|
|
695
|
+
private async pollMessages(): Promise<void> {
|
|
696
|
+
if (!this.running) return;
|
|
697
|
+
|
|
698
|
+
for (const channel of this.config.channels) {
|
|
699
|
+
try {
|
|
700
|
+
const after = this.channelCursor.get(channel);
|
|
701
|
+
const query: { limit: number; after?: string } = { limit: 50 };
|
|
702
|
+
if (after) {
|
|
703
|
+
query.after = after;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const messages = await this.relaycast.messages.list(channel, query);
|
|
707
|
+
const ordered = [...messages].sort((a, b) =>
|
|
708
|
+
a.createdAt.localeCompare(b.createdAt),
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
for (const message of ordered) {
|
|
712
|
+
await this.handleInbound(this.normalizePolledMessage(channel, message));
|
|
713
|
+
}
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.warn(`[gateway] Poll error for #${channel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private async handleInbound(message: InboundMessage): Promise<void> {
|
|
721
|
+
if (!this.running) return;
|
|
722
|
+
if (this.processingMessageIds.has(message.id) || this.isSeen(message.id)) return;
|
|
723
|
+
|
|
724
|
+
// Avoid echo loops — skip messages from this claw or its viewer identity.
|
|
725
|
+
const viewerName = `viewer-${this.config.clawName}`;
|
|
726
|
+
if (message.from === this.config.clawName || message.from === viewerName) {
|
|
727
|
+
this.channelCursor.set(normalizeChannelName(message.channel), message.id);
|
|
728
|
+
this.markSeen(message.id);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Mark as seen immediately to prevent duplicate delivery from concurrent
|
|
733
|
+
// realtime + poll paths processing the same message.
|
|
734
|
+
this.markSeen(message.id);
|
|
735
|
+
this.processingMessageIds.add(message.id);
|
|
736
|
+
|
|
737
|
+
console.log(`[gateway] Delivering message ${message.id} from @${message.from}: "${message.text}"`);
|
|
738
|
+
try {
|
|
739
|
+
const result = await this.onMessage(message);
|
|
740
|
+
console.log(`[gateway] Delivery result: ${result.method} ok=${result.ok}${result.error ? ' error=' + result.error : ''}`);
|
|
741
|
+
this.channelCursor.set(normalizeChannelName(message.channel), message.id);
|
|
742
|
+
} finally {
|
|
743
|
+
this.processingMessageIds.delete(message.id);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Format delivery text with channel, sender, and optional thread prefix. */
|
|
748
|
+
private formatDeliveryText(message: InboundMessage): string {
|
|
749
|
+
const threadPrefix = message.threadParentId ? '[thread] ' : '';
|
|
750
|
+
return `${threadPrefix}[relaycast:${message.channel}] @${message.from}: ${message.text}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Handle an inbound Relaycast message. */
|
|
754
|
+
private async onMessage(message: InboundMessage): Promise<DeliveryResult> {
|
|
755
|
+
// Try primary delivery via the shared relay sender (no extra broker spawned).
|
|
756
|
+
if (this.relaySender) {
|
|
757
|
+
const ok = await this.deliverViaRelaySender(message);
|
|
758
|
+
if (ok) {
|
|
759
|
+
return { ok: true, method: 'relay_sdk' };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Deliver via persistent OpenClaw gateway WebSocket connection
|
|
764
|
+
if (this.openclawClient) {
|
|
765
|
+
const text = this.formatDeliveryText(message);
|
|
766
|
+
const ok = await this.openclawClient.sendChatMessage(text, message.id);
|
|
767
|
+
if (ok) {
|
|
768
|
+
return { ok: true, method: 'gateway_ws' };
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
console.warn(
|
|
773
|
+
`[gateway] Failed to deliver message ${message.id} from @${message.from}`,
|
|
774
|
+
);
|
|
775
|
+
return { ok: false, method: 'failed', error: 'All delivery methods failed' };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Deliver via the caller-provided relay sender (shared broker). */
|
|
779
|
+
private async deliverViaRelaySender(message: InboundMessage): Promise<boolean> {
|
|
780
|
+
if (!this.relaySender) return false;
|
|
781
|
+
|
|
782
|
+
const input: SendMessageInput = {
|
|
783
|
+
to: this.config.clawName,
|
|
784
|
+
text: this.formatDeliveryText(message),
|
|
785
|
+
from: message.from,
|
|
786
|
+
data: {
|
|
787
|
+
source: 'relaycast',
|
|
788
|
+
channel: message.channel,
|
|
789
|
+
messageId: message.id,
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
try {
|
|
794
|
+
const result = await this.relaySender.sendMessage(input);
|
|
795
|
+
return Boolean(result.event_id) && result.event_id !== 'unsupported_operation';
|
|
796
|
+
} catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// -------------------------------------------------------------------------
|
|
802
|
+
// Spawn control HTTP server
|
|
803
|
+
// -------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
private async startControlServer(): Promise<void> {
|
|
806
|
+
const port = Number(process.env.RELAYCAST_CONTROL_PORT) || InboundGateway.DEFAULT_CONTROL_PORT;
|
|
807
|
+
|
|
808
|
+
this.controlServer = createServer((req, res) => {
|
|
809
|
+
void this.handleControlRequest(req, res);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
return new Promise((resolve) => {
|
|
813
|
+
this.controlServer!.listen(port, '127.0.0.1', () => {
|
|
814
|
+
this.controlPort = port;
|
|
815
|
+
console.log(`[gateway] Spawn control API listening on http://127.0.0.1:${port}`);
|
|
816
|
+
resolve();
|
|
817
|
+
});
|
|
818
|
+
this.controlServer!.on('error', (err) => {
|
|
819
|
+
console.warn(`[gateway] Control server failed to start on port ${port}: ${err.message}`);
|
|
820
|
+
this.controlServer = null;
|
|
821
|
+
resolve(); // Non-fatal
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async handleControlRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
827
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
828
|
+
const path = url.pathname;
|
|
829
|
+
|
|
830
|
+
// CORS for local callers
|
|
831
|
+
res.setHeader('Content-Type', 'application/json');
|
|
832
|
+
|
|
833
|
+
if (req.method === 'GET' && path === '/health') {
|
|
834
|
+
res.writeHead(200);
|
|
835
|
+
res.end(JSON.stringify({
|
|
836
|
+
ok: true,
|
|
837
|
+
status: 'running',
|
|
838
|
+
active: this.spawnManager.size,
|
|
839
|
+
uptime: process.uptime(),
|
|
840
|
+
}));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (req.method === 'POST' && path === '/spawn') {
|
|
845
|
+
const body = await readBody(req);
|
|
846
|
+
try {
|
|
847
|
+
const args = JSON.parse(body) as Record<string, unknown>;
|
|
848
|
+
const name = args.name as string;
|
|
849
|
+
if (!name) {
|
|
850
|
+
res.writeHead(400);
|
|
851
|
+
res.end(JSON.stringify({ ok: false, error: '"name" is required' }));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const relayApiKey = this.config.apiKey;
|
|
856
|
+
const spawnOpts: SpawnOptions = {
|
|
857
|
+
name,
|
|
858
|
+
relayApiKey,
|
|
859
|
+
role: (args.role as string) || undefined,
|
|
860
|
+
model: (args.model as string) || undefined,
|
|
861
|
+
channels: (args.channels as string[]) || undefined,
|
|
862
|
+
systemPrompt: (args.system_prompt as string) || undefined,
|
|
863
|
+
relayBaseUrl: this.config.baseUrl,
|
|
864
|
+
workspaceId: (args.workspace_id as string) || process.env.OPENCLAW_WORKSPACE_ID,
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const handle = await this.spawnManager.spawn(spawnOpts);
|
|
868
|
+
res.writeHead(200);
|
|
869
|
+
res.end(JSON.stringify({
|
|
870
|
+
ok: true,
|
|
871
|
+
name: handle.displayName,
|
|
872
|
+
agentName: handle.agentName,
|
|
873
|
+
id: handle.id,
|
|
874
|
+
gatewayPort: handle.gatewayPort,
|
|
875
|
+
active: this.spawnManager.size,
|
|
876
|
+
}));
|
|
877
|
+
} catch (err) {
|
|
878
|
+
res.writeHead(500);
|
|
879
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (req.method === 'GET' && path === '/list') {
|
|
885
|
+
const handles = this.spawnManager.list();
|
|
886
|
+
res.writeHead(200);
|
|
887
|
+
res.end(JSON.stringify({
|
|
888
|
+
ok: true,
|
|
889
|
+
active: handles.length,
|
|
890
|
+
claws: handles.map(h => ({
|
|
891
|
+
name: h.displayName,
|
|
892
|
+
agentName: h.agentName,
|
|
893
|
+
id: h.id,
|
|
894
|
+
gatewayPort: h.gatewayPort,
|
|
895
|
+
})),
|
|
896
|
+
}));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (req.method === 'POST' && path === '/release') {
|
|
901
|
+
const body = await readBody(req);
|
|
902
|
+
try {
|
|
903
|
+
const args = JSON.parse(body) as Record<string, unknown>;
|
|
904
|
+
const name = args.name as string | undefined;
|
|
905
|
+
const id = args.id as string | undefined;
|
|
906
|
+
|
|
907
|
+
if (!name && !id) {
|
|
908
|
+
res.writeHead(400);
|
|
909
|
+
res.end(JSON.stringify({ ok: false, error: 'Provide "name" or "id"' }));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
let released = false;
|
|
914
|
+
if (id) {
|
|
915
|
+
released = await this.spawnManager.release(id);
|
|
916
|
+
} else if (name) {
|
|
917
|
+
released = await this.spawnManager.releaseByName(name);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
res.writeHead(200);
|
|
921
|
+
res.end(JSON.stringify({ ok: released, active: this.spawnManager.size }));
|
|
922
|
+
} catch (err) {
|
|
923
|
+
res.writeHead(500);
|
|
924
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
res.writeHead(404);
|
|
930
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
935
|
+
return new Promise((resolve, reject) => {
|
|
936
|
+
const chunks: Buffer[] = [];
|
|
937
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
938
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
939
|
+
req.on('error', (err) => reject(err));
|
|
940
|
+
});
|
|
941
|
+
}
|