@rethinkingstudio/clawpilot 1.0.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 +79 -0
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +105 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/pair.d.ts +6 -0
- package/dist/commands/pair.js +60 -0
- package/dist/commands/pair.js.map +1 -0
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +27 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +56 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +53 -0
- package/dist/config/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/relay/gateway-client.d.ts +34 -0
- package/dist/relay/gateway-client.js +268 -0
- package/dist/relay/gateway-client.js.map +1 -0
- package/dist/relay/reconnect.d.ts +14 -0
- package/dist/relay/reconnect.js +27 -0
- package/dist/relay/reconnect.js.map +1 -0
- package/dist/relay/relay-manager.d.ts +19 -0
- package/dist/relay/relay-manager.js +101 -0
- package/dist/relay/relay-manager.js.map +1 -0
- package/dist/relay/session-proxy.d.ts +18 -0
- package/dist/relay/session-proxy.js +75 -0
- package/dist/relay/session-proxy.js.map +1 -0
- package/package.json +26 -0
- package/run-relay.sh +24 -0
- package/src/commands/install.ts +110 -0
- package/src/commands/pair.ts +84 -0
- package/src/commands/run.ts +33 -0
- package/src/commands/status.ts +58 -0
- package/src/config/config.ts +67 -0
- package/src/index.ts +70 -0
- package/src/relay/gateway-client.ts +333 -0
- package/src/relay/reconnect.ts +37 -0
- package/src/relay/relay-manager.ts +148 -0
- package/test-chat.mjs +64 -0
- package/test-direct.mjs +171 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { randomUUID, generateKeyPairSync, createPrivateKey, sign, createPublicKey, createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Device identity (Ed25519, persisted across restarts)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const IDENTITY_PATH = join(homedir(), ".clawai", "device-identity.json");
|
|
12
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
13
|
+
|
|
14
|
+
interface DeviceIdentity {
|
|
15
|
+
deviceId: string;
|
|
16
|
+
publicKeyPem: string;
|
|
17
|
+
privateKeyPem: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
21
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rawPublicKeyBytes(publicKeyPem: string): Buffer {
|
|
25
|
+
const key = createPublicKey(publicKeyPem);
|
|
26
|
+
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
|
|
27
|
+
if (
|
|
28
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
29
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
30
|
+
) {
|
|
31
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
32
|
+
}
|
|
33
|
+
return spki;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadOrCreateDeviceIdentity(): DeviceIdentity {
|
|
37
|
+
if (existsSync(IDENTITY_PATH)) {
|
|
38
|
+
try {
|
|
39
|
+
const stored = JSON.parse(readFileSync(IDENTITY_PATH, "utf8")) as DeviceIdentity & { version?: number };
|
|
40
|
+
if (stored.deviceId && stored.publicKeyPem && stored.privateKeyPem) {
|
|
41
|
+
return { deviceId: stored.deviceId, publicKeyPem: stored.publicKeyPem, privateKeyPem: stored.privateKeyPem };
|
|
42
|
+
}
|
|
43
|
+
} catch { /* fall through */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
47
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
48
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
49
|
+
const deviceId = createHash("sha256").update(rawPublicKeyBytes(publicKeyPem)).digest("hex");
|
|
50
|
+
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem };
|
|
51
|
+
|
|
52
|
+
mkdirSync(join(homedir(), ".clawai"), { recursive: true });
|
|
53
|
+
writeFileSync(IDENTITY_PATH, JSON.stringify({ version: 1, ...identity, createdAtMs: Date.now() }, null, 2) + "\n", { mode: 0o600 });
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildSignedDevice(identity: DeviceIdentity, opts: {
|
|
58
|
+
clientId: string;
|
|
59
|
+
clientMode: string;
|
|
60
|
+
role: string;
|
|
61
|
+
scopes: string[];
|
|
62
|
+
signedAtMs: number;
|
|
63
|
+
token?: string;
|
|
64
|
+
nonce?: string;
|
|
65
|
+
}): { id: string; publicKey: string; signature: string; signedAt: number; nonce?: string } {
|
|
66
|
+
const version = opts.nonce ? "v2" : "v1";
|
|
67
|
+
const payload = [
|
|
68
|
+
version,
|
|
69
|
+
identity.deviceId,
|
|
70
|
+
opts.clientId,
|
|
71
|
+
opts.clientMode,
|
|
72
|
+
opts.role,
|
|
73
|
+
opts.scopes.join(","),
|
|
74
|
+
String(opts.signedAtMs),
|
|
75
|
+
opts.token ?? "",
|
|
76
|
+
...(version === "v2" ? [opts.nonce ?? ""] : []),
|
|
77
|
+
].join("|");
|
|
78
|
+
|
|
79
|
+
const key = createPrivateKey(identity.privateKeyPem);
|
|
80
|
+
const signature = base64UrlEncode(sign(null, Buffer.from(payload, "utf8"), key));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id: identity.deviceId,
|
|
84
|
+
publicKey: base64UrlEncode(rawPublicKeyBytes(identity.publicKeyPem)),
|
|
85
|
+
signature,
|
|
86
|
+
signedAt: opts.signedAtMs,
|
|
87
|
+
nonce: opts.nonce,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Wire frame types (OpenClaw protocol v3)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
interface ReqFrame {
|
|
96
|
+
type: "req";
|
|
97
|
+
id: string;
|
|
98
|
+
method: string;
|
|
99
|
+
params?: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ResFrame {
|
|
103
|
+
type: "res";
|
|
104
|
+
id: string;
|
|
105
|
+
ok: boolean;
|
|
106
|
+
payload?: unknown;
|
|
107
|
+
error?: { message?: string };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface EvtFrame {
|
|
111
|
+
type: "event";
|
|
112
|
+
event: string;
|
|
113
|
+
payload?: unknown;
|
|
114
|
+
seq?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PROTOCOL_VERSION = 3;
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Public API
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export interface GatewayClientOptions {
|
|
124
|
+
url: string;
|
|
125
|
+
token?: string;
|
|
126
|
+
password?: string;
|
|
127
|
+
onConnected: () => void;
|
|
128
|
+
onEvent: (eventName: string, payload: unknown) => void;
|
|
129
|
+
onDisconnected: (reason: string) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class OpenClawGatewayClient {
|
|
133
|
+
private ws: WebSocket | null = null;
|
|
134
|
+
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>();
|
|
135
|
+
private backoffMs = 1000;
|
|
136
|
+
private stopped = false;
|
|
137
|
+
private connectNonce: string | null = null;
|
|
138
|
+
private connectSent = false;
|
|
139
|
+
private storedDeviceToken: string | null = null;
|
|
140
|
+
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
141
|
+
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
142
|
+
private lastTick = 0;
|
|
143
|
+
private tickIntervalMs = 30_000;
|
|
144
|
+
private readonly identity: DeviceIdentity;
|
|
145
|
+
|
|
146
|
+
constructor(private readonly opts: GatewayClientOptions) {
|
|
147
|
+
this.identity = loadOrCreateDeviceIdentity();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
start(): void {
|
|
151
|
+
if (this.stopped) return;
|
|
152
|
+
this.ws = new WebSocket(this.opts.url, { maxPayload: 25 * 1024 * 1024 });
|
|
153
|
+
|
|
154
|
+
this.ws.on("open", () => {
|
|
155
|
+
this.connectNonce = null;
|
|
156
|
+
this.connectSent = false;
|
|
157
|
+
// Fallback: send connect after 1 s if challenge hasn't arrived
|
|
158
|
+
this.connectTimer = setTimeout(() => this.sendConnect(), 1000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
this.ws.on("message", (data) => {
|
|
162
|
+
const raw = typeof data === "string" ? data : data.toString();
|
|
163
|
+
this.handleMessage(raw);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this.ws.on("close", (code, reason) => {
|
|
167
|
+
const reasonText = reason.toString() || `code ${code}`;
|
|
168
|
+
this.teardown();
|
|
169
|
+
this.opts.onDisconnected(reasonText);
|
|
170
|
+
this.scheduleReconnect();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
this.ws.on("error", (err) => {
|
|
174
|
+
console.error(`[gateway-client] ws error: ${String(err)}`);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
stop(): void {
|
|
179
|
+
this.stopped = true;
|
|
180
|
+
this.teardown();
|
|
181
|
+
this.ws?.close();
|
|
182
|
+
this.ws = null;
|
|
183
|
+
this.flushPending(new Error("gateway client stopped"));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
send(method: string, params?: unknown): void {
|
|
187
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
188
|
+
throw new Error("gateway not connected");
|
|
189
|
+
}
|
|
190
|
+
const frame: ReqFrame = { type: "req", id: randomUUID(), method, params };
|
|
191
|
+
this.ws.send(JSON.stringify(frame));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
|
|
195
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
196
|
+
throw new Error("gateway not connected");
|
|
197
|
+
}
|
|
198
|
+
const id = randomUUID();
|
|
199
|
+
const frame: ReqFrame = { type: "req", id, method, params };
|
|
200
|
+
const p = new Promise<T>((resolve, reject) => {
|
|
201
|
+
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
|
|
202
|
+
});
|
|
203
|
+
this.ws.send(JSON.stringify(frame));
|
|
204
|
+
return p;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
private sendConnect(): void {
|
|
210
|
+
if (this.connectSent) return;
|
|
211
|
+
this.connectSent = true;
|
|
212
|
+
if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
|
|
213
|
+
|
|
214
|
+
const role = "operator";
|
|
215
|
+
const scopes = ["operator.admin"];
|
|
216
|
+
const clientId = "gateway-client";
|
|
217
|
+
const clientMode = "backend";
|
|
218
|
+
const signedAtMs = Date.now();
|
|
219
|
+
const nonce = this.connectNonce ?? undefined;
|
|
220
|
+
const authToken = this.storedDeviceToken ?? this.opts.token;
|
|
221
|
+
|
|
222
|
+
const device = buildSignedDevice(this.identity, {
|
|
223
|
+
clientId, clientMode, role, scopes, signedAtMs,
|
|
224
|
+
token: authToken ?? undefined,
|
|
225
|
+
nonce,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const params = {
|
|
229
|
+
minProtocol: PROTOCOL_VERSION,
|
|
230
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
231
|
+
role,
|
|
232
|
+
scopes,
|
|
233
|
+
caps: ["tool-events"],
|
|
234
|
+
commands: ["chat.push"],
|
|
235
|
+
client: {
|
|
236
|
+
id: clientId,
|
|
237
|
+
displayName: "ClawAI Relay",
|
|
238
|
+
version: "1.0.0",
|
|
239
|
+
platform: process.platform,
|
|
240
|
+
mode: clientMode,
|
|
241
|
+
},
|
|
242
|
+
device,
|
|
243
|
+
auth: authToken || this.opts.password
|
|
244
|
+
? { token: authToken, password: this.opts.password }
|
|
245
|
+
: undefined,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
this.request<{ policy?: { tickIntervalMs?: number }; auth?: { deviceToken?: string } }>("connect", params)
|
|
249
|
+
.then((helloOk) => {
|
|
250
|
+
const deviceToken = helloOk?.auth?.deviceToken;
|
|
251
|
+
if (typeof deviceToken === "string") {
|
|
252
|
+
this.storedDeviceToken = deviceToken;
|
|
253
|
+
}
|
|
254
|
+
if (typeof helloOk?.policy?.tickIntervalMs === "number") {
|
|
255
|
+
this.tickIntervalMs = helloOk.policy.tickIntervalMs;
|
|
256
|
+
}
|
|
257
|
+
this.backoffMs = 1000;
|
|
258
|
+
this.lastTick = Date.now();
|
|
259
|
+
this.startTickWatch();
|
|
260
|
+
this.opts.onConnected();
|
|
261
|
+
})
|
|
262
|
+
.catch((err: unknown) => {
|
|
263
|
+
console.error(`[gateway-client] connect failed: ${String(err)}`);
|
|
264
|
+
this.ws?.close(1008, "connect failed");
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private handleMessage(raw: string): void {
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
270
|
+
let parsed: any;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(raw);
|
|
273
|
+
} catch {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (typeof parsed?.type !== "string") return;
|
|
277
|
+
|
|
278
|
+
if (parsed.type === "event") {
|
|
279
|
+
const evt = parsed as EvtFrame;
|
|
280
|
+
|
|
281
|
+
if (evt.event === "connect.challenge") {
|
|
282
|
+
const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce;
|
|
283
|
+
if (typeof nonce === "string") {
|
|
284
|
+
this.connectNonce = nonce;
|
|
285
|
+
this.sendConnect();
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (evt.event === "tick") { this.lastTick = Date.now(); return; }
|
|
291
|
+
|
|
292
|
+
this.opts.onEvent(evt.event!, evt.payload ?? null);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (parsed.type === "res") {
|
|
297
|
+
const res = parsed as ResFrame;
|
|
298
|
+
const pending = this.pending.get(res.id);
|
|
299
|
+
if (!pending) return;
|
|
300
|
+
this.pending.delete(res.id);
|
|
301
|
+
if (res.ok) pending.resolve(res.payload);
|
|
302
|
+
else pending.reject(new Error(res.error?.message ?? "gateway error"));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private startTickWatch(): void {
|
|
307
|
+
if (this.tickTimer) clearInterval(this.tickTimer);
|
|
308
|
+
const interval = Math.max(this.tickIntervalMs, 1000);
|
|
309
|
+
this.tickTimer = setInterval(() => {
|
|
310
|
+
if (this.stopped || !this.lastTick) return;
|
|
311
|
+
if (Date.now() - this.lastTick > this.tickIntervalMs * 2) {
|
|
312
|
+
this.ws?.close(4000, "tick timeout");
|
|
313
|
+
}
|
|
314
|
+
}, interval);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private scheduleReconnect(): void {
|
|
318
|
+
if (this.stopped) return;
|
|
319
|
+
const delay = this.backoffMs;
|
|
320
|
+
this.backoffMs = Math.min(this.backoffMs * 2, 30_000);
|
|
321
|
+
setTimeout(() => this.start(), delay).unref();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private teardown(): void {
|
|
325
|
+
if (this.connectTimer) { clearTimeout(this.connectTimer); this.connectTimer = null; }
|
|
326
|
+
if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private flushPending(err: Error): void {
|
|
330
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
331
|
+
this.pending.clear();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface ReconnectOptions {
|
|
2
|
+
initialDelayMs?: number;
|
|
3
|
+
maxDelayMs?: number;
|
|
4
|
+
onRetry?: (attempt: number, delayMs: number) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Implements exponential backoff: 500ms → 1s → 2s → ... → 30s cap.
|
|
9
|
+
* Calls `connect` repeatedly until `connect` returns false (permanent failure)
|
|
10
|
+
* or the returned WebSocket connection stays alive.
|
|
11
|
+
*
|
|
12
|
+
* The `connect` callback should return `true` if it attempted a connection
|
|
13
|
+
* and `false` if it should stop retrying.
|
|
14
|
+
*/
|
|
15
|
+
export async function withReconnect(
|
|
16
|
+
connect: () => Promise<boolean>,
|
|
17
|
+
opts: ReconnectOptions = {}
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const initialDelay = opts.initialDelayMs ?? 500;
|
|
20
|
+
const maxDelay = opts.maxDelayMs ?? 30_000;
|
|
21
|
+
let attempt = 0;
|
|
22
|
+
let delay = initialDelay;
|
|
23
|
+
|
|
24
|
+
while (true) {
|
|
25
|
+
const shouldRetry = await connect();
|
|
26
|
+
if (!shouldRetry) break;
|
|
27
|
+
|
|
28
|
+
attempt++;
|
|
29
|
+
opts.onRetry?.(attempt, delay);
|
|
30
|
+
await sleep(delay);
|
|
31
|
+
delay = Math.min(delay * 2, maxDelay);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleep(ms: number): Promise<void> {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { OpenClawGatewayClient } from "./gateway-client.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Messages: relay client ↔ relay server
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Messages the relay client sends to the relay server. */
|
|
9
|
+
type ToServer =
|
|
10
|
+
| { type: "gateway_connected" }
|
|
11
|
+
| { type: "gateway_disconnected"; reason: string }
|
|
12
|
+
| { type: "event"; event: string; payload: unknown }
|
|
13
|
+
| { type: "res"; id: string; ok: boolean; payload?: unknown; error?: { message?: string } };
|
|
14
|
+
|
|
15
|
+
/** Messages the relay server sends to the relay client. */
|
|
16
|
+
interface FromServer {
|
|
17
|
+
type: "cmd";
|
|
18
|
+
id?: string;
|
|
19
|
+
method: string;
|
|
20
|
+
params: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Options
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface RelayManagerOptions {
|
|
28
|
+
relayServerUrl: string;
|
|
29
|
+
gatewayId: string;
|
|
30
|
+
relaySecret: string;
|
|
31
|
+
gatewayUrl: string;
|
|
32
|
+
gatewayToken?: string;
|
|
33
|
+
gatewayPassword?: string;
|
|
34
|
+
onConnected?: () => void;
|
|
35
|
+
onDisconnected?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Main entry point
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Connects to the cloud relay server and the local OpenClaw Gateway,
|
|
44
|
+
* then bridges messages between them indefinitely.
|
|
45
|
+
*
|
|
46
|
+
* The gateway client runs for as long as this relay connection is alive.
|
|
47
|
+
* Returns a Promise that resolves `true` (retry) when the relay server
|
|
48
|
+
* connection closes.
|
|
49
|
+
*/
|
|
50
|
+
export async function runRelayManager(opts: RelayManagerOptions): Promise<boolean> {
|
|
51
|
+
const wsUrl = buildRelayUrl(opts.relayServerUrl, opts.gatewayId, opts.relaySecret);
|
|
52
|
+
|
|
53
|
+
return new Promise<boolean>((resolve) => {
|
|
54
|
+
let relayWs: WebSocket;
|
|
55
|
+
try {
|
|
56
|
+
relayWs = new WebSocket(wsUrl);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error("Failed to create relay WebSocket:", err);
|
|
59
|
+
resolve(true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let gatewayClient: OpenClawGatewayClient | null = null;
|
|
64
|
+
|
|
65
|
+
function send(msg: ToServer): void {
|
|
66
|
+
if (relayWs.readyState === WebSocket.OPEN) {
|
|
67
|
+
relayWs.send(JSON.stringify(msg));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
relayWs.on("open", () => {
|
|
72
|
+
console.log(`Connected to relay server (gatewayId=${opts.gatewayId})`);
|
|
73
|
+
opts.onConnected?.();
|
|
74
|
+
|
|
75
|
+
// Start the persistent gateway connection as soon as we're connected
|
|
76
|
+
// to the relay server. Its lifetime is tied to this relay session.
|
|
77
|
+
gatewayClient = new OpenClawGatewayClient({
|
|
78
|
+
url: opts.gatewayUrl,
|
|
79
|
+
token: opts.gatewayToken,
|
|
80
|
+
password: opts.gatewayPassword,
|
|
81
|
+
|
|
82
|
+
onConnected: () => {
|
|
83
|
+
console.log("Gateway connected.");
|
|
84
|
+
send({ type: "gateway_connected" });
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
onDisconnected: (reason) => {
|
|
88
|
+
console.log(`Gateway disconnected: ${reason}`);
|
|
89
|
+
send({ type: "gateway_disconnected", reason });
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
onEvent: (event, payload) => {
|
|
93
|
+
send({ type: "event", event, payload });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
gatewayClient.start();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
relayWs.on("message", (raw) => {
|
|
101
|
+
let msg: FromServer;
|
|
102
|
+
try {
|
|
103
|
+
msg = JSON.parse(raw.toString()) as FromServer;
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (msg.type !== "cmd" || !msg.method) return;
|
|
109
|
+
|
|
110
|
+
const requestId = msg.id;
|
|
111
|
+
console.log(`[relay] cmd received method=${msg.method} id=${requestId ?? "(no-id)"}`);
|
|
112
|
+
gatewayClient
|
|
113
|
+
?.request(msg.method, msg.params)
|
|
114
|
+
.then((result) => {
|
|
115
|
+
console.log(`[relay] cmd ok method=${msg.method} id=${requestId ?? "(no-id)"}`);
|
|
116
|
+
if (requestId) {
|
|
117
|
+
send({ type: "res", id: requestId, ok: true, payload: result });
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
.catch((err: unknown) => {
|
|
121
|
+
console.error(`[relay] cmd failed method=${msg.method} id=${requestId ?? "(no-id)"}: ${String(err)}`);
|
|
122
|
+
if (requestId) {
|
|
123
|
+
send({ type: "res", id: requestId, ok: false, error: { message: String(err) } });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
relayWs.on("close", (code, reason) => {
|
|
129
|
+
console.log(`Relay connection closed: ${code} ${reason.toString()}`);
|
|
130
|
+
opts.onDisconnected?.();
|
|
131
|
+
gatewayClient?.stop();
|
|
132
|
+
gatewayClient = null;
|
|
133
|
+
// Code 4000 = server kicked us because another relay client took over.
|
|
134
|
+
// Stop retrying so the two instances don't bounce each other forever.
|
|
135
|
+
resolve(code !== 4000);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
relayWs.on("error", (err) => {
|
|
139
|
+
console.error("Relay WebSocket error:", err.message);
|
|
140
|
+
// close event will follow
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildRelayUrl(serverUrl: string, gatewayId: string, relaySecret: string): string {
|
|
146
|
+
const base = serverUrl.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
147
|
+
return `${base}/relay/${gatewayId}?secret=${encodeURIComponent(relaySecret)}`;
|
|
148
|
+
}
|
package/test-chat.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
|
|
5
|
+
const token = readFileSync("/tmp/test_token.txt", "utf8").trim();
|
|
6
|
+
const url = `ws://localhost:3000/gw/295c6252c69746124af48ebbe7001f11?token=${token}`;
|
|
7
|
+
|
|
8
|
+
const ws = new WebSocket(url);
|
|
9
|
+
|
|
10
|
+
ws.on("open", () => {
|
|
11
|
+
console.log("[ios] connected to relay server");
|
|
12
|
+
|
|
13
|
+
ws.send(JSON.stringify({ method: "sessions.reset", params: { key: "main" } }));
|
|
14
|
+
console.log("[ios] → sessions.reset { key: 'main' }");
|
|
15
|
+
|
|
16
|
+
setTimeout(() => {
|
|
17
|
+
ws.send(JSON.stringify({
|
|
18
|
+
method: "chat.send",
|
|
19
|
+
params: {
|
|
20
|
+
sessionKey: "main",
|
|
21
|
+
message: "hello, please reply with just one short sentence",
|
|
22
|
+
idempotencyKey: randomUUID(),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
console.log("[ios] → chat.send");
|
|
26
|
+
}, 800);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let lastText = "";
|
|
30
|
+
ws.on("message", (raw) => {
|
|
31
|
+
const msg = JSON.parse(raw.toString());
|
|
32
|
+
if (msg.type === "connected") { console.log("[ios] ← gateway online"); return; }
|
|
33
|
+
if (msg.type === "disconnected") { console.log("[gateway] ← disconnected:", msg.reason); return; }
|
|
34
|
+
if (msg.type === "event" && msg.event === "chat") {
|
|
35
|
+
const p = msg.payload;
|
|
36
|
+
if (p.state === "delta") {
|
|
37
|
+
const text = p.message?.content?.[0]?.text ?? "";
|
|
38
|
+
if (text !== lastText) {
|
|
39
|
+
process.stdout.write("\r[delta] " + text.slice(-80).padEnd(80));
|
|
40
|
+
lastText = text;
|
|
41
|
+
}
|
|
42
|
+
} else if (p.state === "final") {
|
|
43
|
+
const text = p.message?.content?.[0]?.text ?? "(no text)";
|
|
44
|
+
console.log("\n\n[FINAL RESPONSE]\n" + text);
|
|
45
|
+
ws.close();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
} else if (p.state === "error") {
|
|
48
|
+
console.log("\n[ERROR]", p.errorMessage);
|
|
49
|
+
ws.close();
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (msg.event !== "health" && msg.event !== "presence") {
|
|
55
|
+
console.log("[event]", JSON.stringify(msg).slice(0, 120));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
ws.on("error", (e) => console.error("[ws error]", e.message));
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
console.log("\n[timeout after 60s]");
|
|
62
|
+
ws.close();
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}, 60000);
|