@clawpilot-app/link 0.1.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 +48 -0
- package/package.json +43 -0
- package/scripts/check-node-version.mjs +44 -0
- package/src/cli.js +599 -0
- package/src/constants.js +1 -0
- package/src/daemon.js +1661 -0
- package/src/i18n.js +182 -0
- package/src/network.js +71 -0
- package/src/openclaw.js +297 -0
- package/src/runtime.js +423 -0
- package/src/server-api.js +135 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,1661 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
9
|
+
import {
|
|
10
|
+
clearCredentials,
|
|
11
|
+
loadConfig,
|
|
12
|
+
loadCredentials,
|
|
13
|
+
loadState,
|
|
14
|
+
logger,
|
|
15
|
+
normalizeHttpsBaseUrl,
|
|
16
|
+
normalizeNonEmptyString,
|
|
17
|
+
parseTimestamp,
|
|
18
|
+
patchState,
|
|
19
|
+
runtimePaths,
|
|
20
|
+
saveCredentials,
|
|
21
|
+
} from "./runtime.js";
|
|
22
|
+
import {
|
|
23
|
+
buildLocalGatewayRequestHeaders,
|
|
24
|
+
detectOpenClawBackend,
|
|
25
|
+
installSkill,
|
|
26
|
+
writeBackendCache,
|
|
27
|
+
} from "./openclaw.js";
|
|
28
|
+
import { LINK_DIRECT_PORT } from "./network.js";
|
|
29
|
+
import {
|
|
30
|
+
createAccessToken,
|
|
31
|
+
ServerApiError,
|
|
32
|
+
updateLinkStatus,
|
|
33
|
+
} from "./server-api.js";
|
|
34
|
+
import { createTranslator, resolveLanguage } from "./i18n.js";
|
|
35
|
+
import { LINK_VERSION } from "./constants.js";
|
|
36
|
+
|
|
37
|
+
const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
|
38
|
+
const BACKEND_PROBE_INTERVAL_MS = 30_000;
|
|
39
|
+
const RELAY_RECONNECT_BASE_DELAY_MS = 3_000;
|
|
40
|
+
const RELAY_RECONNECT_MAX_DELAY_MS = 60_000;
|
|
41
|
+
const RELAY_PING_INTERVAL_MS = 25_000;
|
|
42
|
+
const RELAY_PONG_TIMEOUT_MS = 20_000;
|
|
43
|
+
const LOCAL_CONNECT_TIMEOUT_MS = 8_000;
|
|
44
|
+
const LOCAL_APP_CONNECT_TIMEOUT_MS = 6_000;
|
|
45
|
+
const GATEWAY_PROTOCOL_VERSION = 3;
|
|
46
|
+
const LOCAL_GATEWAY_ROLE = "operator";
|
|
47
|
+
const LOCAL_GATEWAY_SCOPES = [
|
|
48
|
+
"operator.read",
|
|
49
|
+
"operator.write",
|
|
50
|
+
"operator.admin",
|
|
51
|
+
"operator.approvals",
|
|
52
|
+
"operator.pairing",
|
|
53
|
+
];
|
|
54
|
+
const LOCAL_GATEWAY_CAPS = ["tool-events"];
|
|
55
|
+
|
|
56
|
+
const daemonLogger = logger("daemon");
|
|
57
|
+
|
|
58
|
+
const LINK_REFRESH_TOKEN_TERMINAL_ERROR_CODES = new Set([
|
|
59
|
+
"LINK_REFRESH_TOKEN_INVALID",
|
|
60
|
+
"LINK_REFRESH_TOKEN_REVOKED",
|
|
61
|
+
"LINK_REFRESH_TOKEN_EXPIRED",
|
|
62
|
+
"LINK_REVOKED",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
function buildRelayControlUrl(relayBaseUrl) {
|
|
66
|
+
const parsed = new URL(relayBaseUrl.replace(/\/+$/, ""));
|
|
67
|
+
parsed.protocol = "wss:";
|
|
68
|
+
parsed.pathname = "/link/connect";
|
|
69
|
+
parsed.search = "";
|
|
70
|
+
parsed.hash = "";
|
|
71
|
+
return parsed.toString();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toBase64Url(buffer) {
|
|
75
|
+
return Buffer.from(buffer)
|
|
76
|
+
.toString("base64")
|
|
77
|
+
.replace(/\+/g, "-")
|
|
78
|
+
.replace(/\//g, "_")
|
|
79
|
+
.replace(/=+$/g, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fromBase64Url(value) {
|
|
83
|
+
const normalized = String(value ?? "").replace(/-/g, "+").replace(/_/g, "/");
|
|
84
|
+
const remainder = normalized.length % 4;
|
|
85
|
+
return Buffer.from(normalized + (remainder === 0 ? "" : "=".repeat(4 - remainder)), "base64");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeJsonParse(raw) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildFrame(type, payload = {}) {
|
|
97
|
+
return JSON.stringify({
|
|
98
|
+
v: 1,
|
|
99
|
+
type,
|
|
100
|
+
payload,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createRequestId(prefix) {
|
|
105
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createNonce(byteLength = 16) {
|
|
109
|
+
return crypto.randomBytes(byteLength)
|
|
110
|
+
.toString("base64")
|
|
111
|
+
.replace(/\+/g, "-")
|
|
112
|
+
.replace(/\//g, "_")
|
|
113
|
+
.replace(/=+$/g, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sanitizeCloseCode(code, fallback = 1011) {
|
|
117
|
+
if (typeof code !== "number" || !Number.isInteger(code)) {
|
|
118
|
+
return fallback;
|
|
119
|
+
}
|
|
120
|
+
if (code >= 1000 && code <= 4999) {
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
return fallback;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeNodeErrorCode(error) {
|
|
127
|
+
return error && typeof error === "object" && typeof error.code === "string"
|
|
128
|
+
? error.code.trim()
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildGatewayError(message, code = "gateway_error") {
|
|
133
|
+
return {
|
|
134
|
+
code,
|
|
135
|
+
message: normalizeNonEmptyString(message) ?? "Request failed",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isTerminalLinkRefreshTokenError(error) {
|
|
140
|
+
return (
|
|
141
|
+
error instanceof ServerApiError &&
|
|
142
|
+
(error.status === 401 || error.status === 403 || error.status === 410) &&
|
|
143
|
+
LINK_REFRESH_TOKEN_TERMINAL_ERROR_CODES.has(error.errorCode ?? "")
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseGatewayFrame(raw) {
|
|
148
|
+
const parsed = safeJsonParse(raw);
|
|
149
|
+
if (!parsed || typeof parsed !== "object") {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeRequestPath(rawUrl) {
|
|
156
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) {
|
|
157
|
+
return "/";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const parsed = new URL(rawUrl, "http://clawpilot-link.local");
|
|
162
|
+
const pathname = parsed.pathname || "/";
|
|
163
|
+
const search = parsed.search || "";
|
|
164
|
+
return `${pathname}${search}` || "/";
|
|
165
|
+
} catch {
|
|
166
|
+
return "/";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectIncomingRequestHeaders(headers) {
|
|
171
|
+
const result = {};
|
|
172
|
+
for (const [rawName, rawValue] of Object.entries(headers ?? {})) {
|
|
173
|
+
const name = String(rawName ?? "").trim().toLowerCase();
|
|
174
|
+
if (!name) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
name === "host" ||
|
|
179
|
+
name === "connection" ||
|
|
180
|
+
name === "upgrade" ||
|
|
181
|
+
name === "authorization" ||
|
|
182
|
+
name === "content-length" ||
|
|
183
|
+
name === "transfer-encoding"
|
|
184
|
+
) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
|
|
189
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
result[name] = value;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractBearerToken(headerValue) {
|
|
198
|
+
if (typeof headerValue !== "string") {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const match = headerValue.match(/^Bearer\s+(.+)$/i);
|
|
202
|
+
return normalizeNonEmptyString(match?.[1]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function readIncomingRequestBody(request) {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
for await (const chunk of request) {
|
|
208
|
+
if (typeof chunk === "string") {
|
|
209
|
+
chunks.push(Buffer.from(chunk, "utf8"));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
chunks.push(Buffer.from(chunk));
|
|
213
|
+
}
|
|
214
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function collectResponseHeaders(headers) {
|
|
218
|
+
const result = {};
|
|
219
|
+
for (const [name, value] of headers.entries()) {
|
|
220
|
+
if (!name || !value) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
result[name] = value;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sanitizeHelloPayload(payload) {
|
|
229
|
+
if (!payload || typeof payload !== "object") {
|
|
230
|
+
return {
|
|
231
|
+
type: "hello-ok",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const nextPayload = {
|
|
236
|
+
...payload,
|
|
237
|
+
};
|
|
238
|
+
if (payload.auth && typeof payload.auth === "object") {
|
|
239
|
+
const nextAuth = {
|
|
240
|
+
...payload.auth,
|
|
241
|
+
};
|
|
242
|
+
delete nextAuth.deviceToken;
|
|
243
|
+
delete nextAuth.deviceTokens;
|
|
244
|
+
nextPayload.auth = nextAuth;
|
|
245
|
+
}
|
|
246
|
+
return nextPayload;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function isDaemonRunning() {
|
|
250
|
+
try {
|
|
251
|
+
await sendIpcRequest("status.get", {});
|
|
252
|
+
return true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function spawnBackgroundDaemon() {
|
|
259
|
+
const cliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
260
|
+
const child = spawn(process.execPath, [cliPath, "daemon"], {
|
|
261
|
+
detached: true,
|
|
262
|
+
stdio: "ignore",
|
|
263
|
+
env: {
|
|
264
|
+
...process.env,
|
|
265
|
+
CLAWLINK_DAEMON: "1",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
child.unref();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function waitForDaemonReady(timeoutMs = 5_000) {
|
|
272
|
+
const startedAt = Date.now();
|
|
273
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
274
|
+
try {
|
|
275
|
+
await sendIpcRequest("status.get", {}, 500);
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function sendIpcRequest(method, params = {}, timeoutMs = 4_000) {
|
|
285
|
+
return await new Promise((resolve, reject) => {
|
|
286
|
+
const socket = net.createConnection(runtimePaths.socketFile);
|
|
287
|
+
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
288
|
+
let settled = false;
|
|
289
|
+
let buffer = "";
|
|
290
|
+
|
|
291
|
+
const finish = (callback, value) => {
|
|
292
|
+
if (settled) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
settled = true;
|
|
296
|
+
clearTimeout(timeoutId);
|
|
297
|
+
socket.destroy();
|
|
298
|
+
callback(value);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const timeoutId = setTimeout(() => {
|
|
302
|
+
finish(reject, new Error("ipc_timeout"));
|
|
303
|
+
}, timeoutMs);
|
|
304
|
+
|
|
305
|
+
socket.on("connect", () => {
|
|
306
|
+
socket.write(`${JSON.stringify({ id: requestId, method, params })}\n`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
socket.on("data", (chunk) => {
|
|
310
|
+
buffer += chunk.toString("utf8");
|
|
311
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
312
|
+
while (newlineIndex >= 0) {
|
|
313
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
314
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
315
|
+
if (line) {
|
|
316
|
+
const parsed = safeJsonParse(line);
|
|
317
|
+
if (parsed?.id === requestId) {
|
|
318
|
+
if (parsed.ok) {
|
|
319
|
+
finish(resolve, parsed.result);
|
|
320
|
+
} else {
|
|
321
|
+
finish(reject, new Error(parsed.error?.message ?? "ipc_error"));
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
newlineIndex = buffer.indexOf("\n");
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
socket.on("error", (error) => {
|
|
331
|
+
finish(reject, error);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export class LinkDaemon {
|
|
337
|
+
constructor(config, state, credentials) {
|
|
338
|
+
this.config = config;
|
|
339
|
+
this.state = state;
|
|
340
|
+
this.credentials = credentials;
|
|
341
|
+
this.language = resolveLanguage(config.language);
|
|
342
|
+
this.t = createTranslator(this.language);
|
|
343
|
+
this.server = null;
|
|
344
|
+
this.localGatewayServer = null;
|
|
345
|
+
this.localGatewayWsServer = null;
|
|
346
|
+
this.relaySocket = null;
|
|
347
|
+
this.relayConnected = false;
|
|
348
|
+
this.relayConnecting = false;
|
|
349
|
+
this.backendProbeTimer = null;
|
|
350
|
+
this.relayPingTimer = null;
|
|
351
|
+
this.relayPongTimeoutTimer = null;
|
|
352
|
+
this.relayAwaitingPong = false;
|
|
353
|
+
this.relayReconnectTimer = null;
|
|
354
|
+
this.relayReconnectAttempts = 0;
|
|
355
|
+
this.relayCredentialState = "ready";
|
|
356
|
+
this.stopped = false;
|
|
357
|
+
this.gatewaySessions = new Map();
|
|
358
|
+
this.localAppSockets = new Map();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async start() {
|
|
362
|
+
const relayBaseUrl = normalizeHttpsBaseUrl(this.config.relayBaseUrl);
|
|
363
|
+
if (!relayBaseUrl) {
|
|
364
|
+
throw new Error(this.t.t("relayConfigInsecure", {
|
|
365
|
+
configFile: runtimePaths.configFile,
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
this.config = {
|
|
369
|
+
...this.config,
|
|
370
|
+
relayBaseUrl,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
await daemonLogger.info("daemon_starting", { pid: process.pid });
|
|
374
|
+
await patchState({
|
|
375
|
+
daemon: {
|
|
376
|
+
pid: process.pid,
|
|
377
|
+
startedAt: new Date().toISOString(),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await this.refreshBackendState();
|
|
382
|
+
await installSkill(this.language);
|
|
383
|
+
await this.openIpcServer();
|
|
384
|
+
await this.openLocalGatewayServer();
|
|
385
|
+
this.startBackendProbeLoop();
|
|
386
|
+
await this.ensureRelayConnection();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async stop() {
|
|
390
|
+
if (this.stopped) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.stopped = true;
|
|
394
|
+
clearInterval(this.backendProbeTimer);
|
|
395
|
+
clearInterval(this.relayPingTimer);
|
|
396
|
+
clearTimeout(this.relayPongTimeoutTimer);
|
|
397
|
+
clearTimeout(this.relayReconnectTimer);
|
|
398
|
+
|
|
399
|
+
for (const appSocketState of this.localAppSockets.values()) {
|
|
400
|
+
clearTimeout(appSocketState.authTimer);
|
|
401
|
+
try {
|
|
402
|
+
appSocketState.socket.close();
|
|
403
|
+
} catch {
|
|
404
|
+
// Ignore close failures.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
this.localAppSockets.clear();
|
|
408
|
+
|
|
409
|
+
for (const gatewaySession of this.gatewaySessions.values()) {
|
|
410
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
411
|
+
try {
|
|
412
|
+
gatewaySession.socket.close();
|
|
413
|
+
} catch {
|
|
414
|
+
// Ignore local socket close failures.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.gatewaySessions.clear();
|
|
418
|
+
|
|
419
|
+
if (this.relaySocket) {
|
|
420
|
+
try {
|
|
421
|
+
this.relaySocket.close();
|
|
422
|
+
} catch {
|
|
423
|
+
// Ignore close failures.
|
|
424
|
+
}
|
|
425
|
+
this.relaySocket = null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (this.server) {
|
|
429
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
430
|
+
this.server = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (this.localGatewayWsServer) {
|
|
434
|
+
await new Promise((resolve) => this.localGatewayWsServer.close(() => resolve()));
|
|
435
|
+
this.localGatewayWsServer = null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (this.localGatewayServer) {
|
|
439
|
+
await new Promise((resolve) => this.localGatewayServer.close(() => resolve()));
|
|
440
|
+
this.localGatewayServer = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (process.platform !== "win32") {
|
|
444
|
+
try {
|
|
445
|
+
await import("node:fs/promises").then(({ unlink }) => unlink(runtimePaths.socketFile));
|
|
446
|
+
} catch {
|
|
447
|
+
// Ignore stale socket cleanup failures.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const nextStatus = this.credentials.linkId ? "paired" : "new";
|
|
452
|
+
this.state = await patchState({
|
|
453
|
+
connectionStatus: nextStatus,
|
|
454
|
+
daemon: {
|
|
455
|
+
pid: null,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
startBackendProbeLoop() {
|
|
463
|
+
this.backendProbeTimer = setInterval(() => {
|
|
464
|
+
void this.refreshBackendState().then(async () => {
|
|
465
|
+
await this.openLocalGatewayServer();
|
|
466
|
+
await this.ensureRelayConnection();
|
|
467
|
+
});
|
|
468
|
+
}, BACKEND_PROBE_INTERVAL_MS);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async refreshBackendState() {
|
|
472
|
+
const backend = await detectOpenClawBackend();
|
|
473
|
+
await writeBackendCache(backend);
|
|
474
|
+
this.state = await patchState((current) => ({
|
|
475
|
+
backend,
|
|
476
|
+
connectionStatus: this.resolveConnectionStatus({
|
|
477
|
+
relayConnected: this.relayConnected,
|
|
478
|
+
relayConnecting: this.relayConnecting,
|
|
479
|
+
backend,
|
|
480
|
+
}),
|
|
481
|
+
lastErrorMessage: backend.detected ? backend.message : current.lastErrorMessage,
|
|
482
|
+
}));
|
|
483
|
+
await daemonLogger.info("backend_probe", {
|
|
484
|
+
detected: backend.detected,
|
|
485
|
+
healthy: backend.healthy,
|
|
486
|
+
supported: backend.supported,
|
|
487
|
+
message: backend.message,
|
|
488
|
+
port: backend.port,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
resolveConnectionStatus(params = {}) {
|
|
493
|
+
const backend = params.backend ?? this.state.backend;
|
|
494
|
+
if (params.forceRevoked || this.relayCredentialState === "revoked") {
|
|
495
|
+
return "revoked";
|
|
496
|
+
}
|
|
497
|
+
if (!this.credentials.linkId) {
|
|
498
|
+
return this.state.pairingSession ? "pairing" : "new";
|
|
499
|
+
}
|
|
500
|
+
if (!backend.detected) {
|
|
501
|
+
return "backend_missing";
|
|
502
|
+
}
|
|
503
|
+
if (!backend.supported || !backend.healthy) {
|
|
504
|
+
return "degraded";
|
|
505
|
+
}
|
|
506
|
+
if (params.relayConnected || this.relayConnected) {
|
|
507
|
+
return "connected";
|
|
508
|
+
}
|
|
509
|
+
if (params.relayConnecting || this.relayConnecting) {
|
|
510
|
+
return "connecting";
|
|
511
|
+
}
|
|
512
|
+
return "paired";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async ensureAccessToken(force = false) {
|
|
516
|
+
if (this.relayCredentialState === "revoked") {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!normalizeNonEmptyString(this.credentials.refreshToken)) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const expiresAtMs = parseTimestamp(this.credentials.accessTokenExpiresAt);
|
|
525
|
+
if (!force && normalizeNonEmptyString(this.credentials.accessToken) && expiresAtMs - Date.now() > ACCESS_TOKEN_REFRESH_THRESHOLD_MS) {
|
|
526
|
+
return this.credentials.accessToken;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const response = await createAccessToken({
|
|
531
|
+
apiBaseUrl: this.config.apiBaseUrl,
|
|
532
|
+
refreshToken: this.credentials.refreshToken,
|
|
533
|
+
});
|
|
534
|
+
this.relayCredentialState = "ready";
|
|
535
|
+
this.credentials = {
|
|
536
|
+
...this.credentials,
|
|
537
|
+
linkId: response.link?.linkId ?? this.credentials.linkId,
|
|
538
|
+
accessToken: response.accessToken?.token ?? null,
|
|
539
|
+
accessTokenExpiresAt: response.accessToken?.expiresAt ?? null,
|
|
540
|
+
};
|
|
541
|
+
await saveCredentials(this.credentials);
|
|
542
|
+
this.state = await patchState({
|
|
543
|
+
linkId: this.credentials.linkId,
|
|
544
|
+
});
|
|
545
|
+
return this.credentials.accessToken;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (isTerminalLinkRefreshTokenError(error)) {
|
|
548
|
+
const terminalMessage =
|
|
549
|
+
error.errorCode === "LINK_REVOKED"
|
|
550
|
+
? this.t.t("linkRevoked")
|
|
551
|
+
: this.t.t("linkCredentialsExpired");
|
|
552
|
+
await this.handleRevokedCredentials(terminalMessage, error);
|
|
553
|
+
}
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async handleRevokedCredentials(message, error = null) {
|
|
559
|
+
this.relayCredentialState = "revoked";
|
|
560
|
+
clearInterval(this.relayPingTimer);
|
|
561
|
+
this.relayPingTimer = null;
|
|
562
|
+
clearTimeout(this.relayPongTimeoutTimer);
|
|
563
|
+
this.relayPongTimeoutTimer = null;
|
|
564
|
+
this.relayAwaitingPong = false;
|
|
565
|
+
clearTimeout(this.relayReconnectTimer);
|
|
566
|
+
this.relayReconnectTimer = null;
|
|
567
|
+
this.relayConnected = false;
|
|
568
|
+
this.relayConnecting = false;
|
|
569
|
+
this.relayReconnectAttempts = 0;
|
|
570
|
+
|
|
571
|
+
if (this.relaySocket) {
|
|
572
|
+
try {
|
|
573
|
+
this.relaySocket.close();
|
|
574
|
+
} catch {
|
|
575
|
+
// Ignore close failures.
|
|
576
|
+
}
|
|
577
|
+
this.relaySocket = null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
await this.closeGatewaySessionsByRouteKind("relay", "Relay credentials revoked");
|
|
581
|
+
|
|
582
|
+
this.credentials = {
|
|
583
|
+
...this.credentials,
|
|
584
|
+
accessToken: null,
|
|
585
|
+
accessTokenExpiresAt: null,
|
|
586
|
+
refreshToken: null,
|
|
587
|
+
refreshTokenExpiresAt: null,
|
|
588
|
+
};
|
|
589
|
+
await saveCredentials(this.credentials);
|
|
590
|
+
this.state = await patchState({
|
|
591
|
+
connectionStatus: "revoked",
|
|
592
|
+
lastErrorMessage: message,
|
|
593
|
+
daemon: {
|
|
594
|
+
connectedAt: null,
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
await daemonLogger.warn("relay_credentials_revoked", {
|
|
598
|
+
errorCode: error instanceof ServerApiError ? error.errorCode ?? null : null,
|
|
599
|
+
message,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async reportStatus(connectionStatus, lastErrorMessage = null) {
|
|
604
|
+
const now = new Date().toISOString();
|
|
605
|
+
this.state = await patchState((current) => ({
|
|
606
|
+
connectionStatus,
|
|
607
|
+
lastErrorMessage,
|
|
608
|
+
daemon: {
|
|
609
|
+
connectedAt: connectionStatus === "connected" ? now : current.daemon.connectedAt,
|
|
610
|
+
lastHeartbeatAt: now,
|
|
611
|
+
},
|
|
612
|
+
}));
|
|
613
|
+
|
|
614
|
+
if (this.relayCredentialState === "revoked") {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const accessToken = await this.ensureAccessToken();
|
|
620
|
+
if (!normalizeNonEmptyString(accessToken)) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
await updateLinkStatus({
|
|
624
|
+
apiBaseUrl: this.config.apiBaseUrl,
|
|
625
|
+
accessToken,
|
|
626
|
+
connectionStatus,
|
|
627
|
+
backendUrl: this.state.backend.httpBaseUrl,
|
|
628
|
+
lastErrorMessage,
|
|
629
|
+
});
|
|
630
|
+
} catch (error) {
|
|
631
|
+
await daemonLogger.warn("status_report_failed", {
|
|
632
|
+
message: error instanceof Error ? error.message : String(error),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async ensureRelayConnection(forceReconnect = false) {
|
|
638
|
+
if (this.stopped || !this.credentials.linkId || this.relayCredentialState === "revoked") {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const backend = this.state.backend;
|
|
643
|
+
if (!backend.supported || !backend.wsEndpoint || !backend.httpBaseUrl) {
|
|
644
|
+
if (this.relaySocket) {
|
|
645
|
+
try {
|
|
646
|
+
this.relaySocket.close();
|
|
647
|
+
} catch {
|
|
648
|
+
// Ignore close failures.
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (this.relaySocket && this.relaySocket.readyState === WebSocket.OPEN && !forceReconnect) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (this.relayConnecting && !forceReconnect) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (forceReconnect && this.relaySocket) {
|
|
663
|
+
try {
|
|
664
|
+
this.relaySocket.close();
|
|
665
|
+
} catch {
|
|
666
|
+
// Ignore close failures.
|
|
667
|
+
}
|
|
668
|
+
this.relaySocket = null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
clearTimeout(this.relayReconnectTimer);
|
|
672
|
+
this.relayReconnectTimer = null;
|
|
673
|
+
this.relayConnecting = true;
|
|
674
|
+
await this.reportStatus("connecting", null);
|
|
675
|
+
|
|
676
|
+
let accessToken;
|
|
677
|
+
try {
|
|
678
|
+
accessToken = await this.ensureAccessToken(forceReconnect);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
this.relayConnecting = false;
|
|
681
|
+
if (this.relayCredentialState === "revoked") {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
await this.reportStatus("degraded", error instanceof Error ? error.message : String(error));
|
|
685
|
+
this.scheduleRelayReconnect();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (!normalizeNonEmptyString(accessToken)) {
|
|
690
|
+
this.relayConnecting = false;
|
|
691
|
+
if (this.relayCredentialState !== "revoked") {
|
|
692
|
+
await this.reportStatus("degraded", this.t.t("linkCredentialsMissing"));
|
|
693
|
+
this.scheduleRelayReconnect();
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const relayUrl = buildRelayControlUrl(this.config.relayBaseUrl);
|
|
699
|
+
const relaySocket = new WebSocket(relayUrl, {
|
|
700
|
+
headers: {
|
|
701
|
+
Authorization: `Bearer ${accessToken}`,
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
this.relaySocket = relaySocket;
|
|
705
|
+
|
|
706
|
+
relaySocket.on("open", () => {
|
|
707
|
+
if (this.relaySocket !== relaySocket || this.stopped) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
this.relayConnecting = false;
|
|
711
|
+
relaySocket.send(buildFrame("hello", {
|
|
712
|
+
linkId: this.credentials.linkId,
|
|
713
|
+
installId: this.config.installId,
|
|
714
|
+
hostname: os.hostname(),
|
|
715
|
+
platform: process.platform,
|
|
716
|
+
version: LINK_VERSION,
|
|
717
|
+
capabilities: {
|
|
718
|
+
gatewayWs: true,
|
|
719
|
+
gatewayHttp: true,
|
|
720
|
+
skill: true,
|
|
721
|
+
},
|
|
722
|
+
}));
|
|
723
|
+
this.relayAwaitingPong = false;
|
|
724
|
+
this.startRelayPingLoop();
|
|
725
|
+
void daemonLogger.info("relay_connected", {
|
|
726
|
+
relayUrl,
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
relaySocket.on("message", (rawData) => {
|
|
731
|
+
if (this.relaySocket !== relaySocket || this.stopped) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const raw = typeof rawData === "string" ? rawData : rawData.toString("utf8");
|
|
735
|
+
void this.handleRelayMessage(raw);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
relaySocket.on("pong", () => {
|
|
739
|
+
if (this.relaySocket !== relaySocket || this.stopped) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.relayAwaitingPong = false;
|
|
743
|
+
clearTimeout(this.relayPongTimeoutTimer);
|
|
744
|
+
this.relayPongTimeoutTimer = null;
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
relaySocket.on("close", () => {
|
|
748
|
+
if (this.relaySocket !== relaySocket && this.relaySocket !== null) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
void this.handleRelayDisconnect("Relay connection closed");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
relaySocket.on("error", (error) => {
|
|
755
|
+
if (this.relaySocket !== relaySocket || this.stopped) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
void daemonLogger.warn("relay_socket_error", {
|
|
759
|
+
message: error instanceof Error ? error.message : String(error),
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
startRelayPingLoop() {
|
|
765
|
+
clearInterval(this.relayPingTimer);
|
|
766
|
+
this.relayPingTimer = setInterval(() => {
|
|
767
|
+
if (!this.relaySocket || this.relaySocket.readyState !== WebSocket.OPEN) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (this.relayAwaitingPong) {
|
|
771
|
+
try {
|
|
772
|
+
this.relaySocket.terminate();
|
|
773
|
+
} catch {
|
|
774
|
+
// Ignore terminate failures.
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
this.relayAwaitingPong = true;
|
|
780
|
+
clearTimeout(this.relayPongTimeoutTimer);
|
|
781
|
+
this.relayPongTimeoutTimer = setTimeout(() => {
|
|
782
|
+
if (!this.relaySocket || this.relaySocket.readyState !== WebSocket.OPEN) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
this.relaySocket.terminate();
|
|
787
|
+
} catch {
|
|
788
|
+
// Ignore terminate failures.
|
|
789
|
+
}
|
|
790
|
+
}, RELAY_PONG_TIMEOUT_MS);
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
this.relaySocket.ping();
|
|
794
|
+
} catch {
|
|
795
|
+
try {
|
|
796
|
+
this.relaySocket.terminate();
|
|
797
|
+
} catch {
|
|
798
|
+
// Ignore terminate failures.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}, RELAY_PING_INTERVAL_MS);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
scheduleRelayReconnect() {
|
|
805
|
+
if (this.relayReconnectTimer || this.stopped || this.relayCredentialState === "revoked") {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const attempt = this.relayReconnectAttempts;
|
|
809
|
+
const baseDelayMs = Math.min(
|
|
810
|
+
RELAY_RECONNECT_BASE_DELAY_MS * 2 ** attempt,
|
|
811
|
+
RELAY_RECONNECT_MAX_DELAY_MS,
|
|
812
|
+
);
|
|
813
|
+
const jitterMs = Math.floor(baseDelayMs * 0.2 * Math.random());
|
|
814
|
+
const delayMs = baseDelayMs + jitterMs;
|
|
815
|
+
this.relayReconnectAttempts = Math.min(attempt + 1, 12);
|
|
816
|
+
this.relayReconnectTimer = setTimeout(() => {
|
|
817
|
+
this.relayReconnectTimer = null;
|
|
818
|
+
void this.ensureRelayConnection();
|
|
819
|
+
}, delayMs);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async handleRelayDisconnect(message) {
|
|
823
|
+
clearInterval(this.relayPingTimer);
|
|
824
|
+
this.relayPingTimer = null;
|
|
825
|
+
clearTimeout(this.relayPongTimeoutTimer);
|
|
826
|
+
this.relayPongTimeoutTimer = null;
|
|
827
|
+
this.relayAwaitingPong = false;
|
|
828
|
+
const previousSocket = this.relaySocket;
|
|
829
|
+
this.relaySocket = null;
|
|
830
|
+
this.relayConnected = false;
|
|
831
|
+
this.relayConnecting = false;
|
|
832
|
+
await this.closeGatewaySessionsByRouteKind("relay", "Relay connection lost");
|
|
833
|
+
|
|
834
|
+
if (this.relayCredentialState === "revoked") {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
await this.reportStatus(
|
|
839
|
+
this.resolveConnectionStatus({ relayConnected: false, relayConnecting: false }),
|
|
840
|
+
message,
|
|
841
|
+
);
|
|
842
|
+
if (previousSocket || this.credentials.linkId) {
|
|
843
|
+
this.scheduleRelayReconnect();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async handleRelayMessage(raw) {
|
|
848
|
+
const frame = safeJsonParse(raw);
|
|
849
|
+
if (!frame || typeof frame.type !== "string") {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const payload = frame.payload && typeof frame.payload === "object" ? frame.payload : {};
|
|
854
|
+
switch (frame.type) {
|
|
855
|
+
case "auth.ok":
|
|
856
|
+
this.relayConnected = true;
|
|
857
|
+
this.relayReconnectAttempts = 0;
|
|
858
|
+
await this.reportStatus("connected", null);
|
|
859
|
+
return;
|
|
860
|
+
case "ping":
|
|
861
|
+
this.sendRelayFrame("pong", { ts: Date.now() });
|
|
862
|
+
return;
|
|
863
|
+
case "pong":
|
|
864
|
+
return;
|
|
865
|
+
case "app.ws.connect":
|
|
866
|
+
await this.openGatewaySession(
|
|
867
|
+
normalizeNonEmptyString(payload.appConnId),
|
|
868
|
+
this.buildRelayGatewaySessionHandlers(normalizeNonEmptyString(payload.appConnId)),
|
|
869
|
+
{
|
|
870
|
+
routeKind: "relay",
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
return;
|
|
874
|
+
case "app.ws.message":
|
|
875
|
+
await this.forwardRelayAppWsMessage(payload);
|
|
876
|
+
return;
|
|
877
|
+
case "app.ws.close":
|
|
878
|
+
await this.closeGatewaySession(normalizeNonEmptyString(payload.appConnId));
|
|
879
|
+
return;
|
|
880
|
+
case "http.request":
|
|
881
|
+
await this.handleRelayHttpRequest(payload);
|
|
882
|
+
return;
|
|
883
|
+
default:
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
sendRelayFrame(type, payload = {}) {
|
|
889
|
+
if (!this.relaySocket || this.relaySocket.readyState !== WebSocket.OPEN) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
this.relaySocket.send(buildFrame(type, payload));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
buildRelayGatewaySessionHandlers(appConnId) {
|
|
896
|
+
if (!appConnId) {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
onConnected: (hello) => {
|
|
902
|
+
this.sendRelayFrame("app.ws.connected", {
|
|
903
|
+
appConnId,
|
|
904
|
+
hello,
|
|
905
|
+
});
|
|
906
|
+
},
|
|
907
|
+
onMessage: (raw) => {
|
|
908
|
+
this.sendRelayFrame("app.ws.message", {
|
|
909
|
+
appConnId,
|
|
910
|
+
data: raw,
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
onConnectError: (error) => {
|
|
914
|
+
this.sendRelayFrame("app.ws.connect_error", {
|
|
915
|
+
appConnId,
|
|
916
|
+
error,
|
|
917
|
+
});
|
|
918
|
+
},
|
|
919
|
+
onClose: ({ code, reason }) => {
|
|
920
|
+
this.sendRelayFrame("app.ws.close", {
|
|
921
|
+
appConnId,
|
|
922
|
+
code,
|
|
923
|
+
reason,
|
|
924
|
+
});
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async openGatewaySession(sessionId, handlers, options = {}) {
|
|
930
|
+
if (!sessionId || !handlers || this.gatewaySessions.has(sessionId) || !this.state.backend.wsEndpoint) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const backend = this.state.backend;
|
|
935
|
+
const socket = new WebSocket(backend.wsEndpoint);
|
|
936
|
+
const gatewaySession = {
|
|
937
|
+
socket,
|
|
938
|
+
queue: [],
|
|
939
|
+
ready: false,
|
|
940
|
+
connectRequestId: null,
|
|
941
|
+
connectSent: false,
|
|
942
|
+
connectFailed: false,
|
|
943
|
+
suppressCloseFrame: false,
|
|
944
|
+
connectTimer: setTimeout(() => {
|
|
945
|
+
if (
|
|
946
|
+
gatewaySession.ready ||
|
|
947
|
+
gatewaySession.connectFailed ||
|
|
948
|
+
gatewaySession.suppressCloseFrame
|
|
949
|
+
) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
gatewaySession.connectFailed = true;
|
|
953
|
+
handlers.onConnectError(buildGatewayError("Local OpenClaw connect timed out"));
|
|
954
|
+
void this.closeGatewaySession(sessionId);
|
|
955
|
+
}, LOCAL_CONNECT_TIMEOUT_MS),
|
|
956
|
+
handlers,
|
|
957
|
+
routeKind: options.routeKind === "lan" ? "lan" : "relay",
|
|
958
|
+
};
|
|
959
|
+
this.gatewaySessions.set(sessionId, gatewaySession);
|
|
960
|
+
|
|
961
|
+
socket.on("message", (rawData) => {
|
|
962
|
+
const raw = typeof rawData === "string" ? rawData : rawData.toString("utf8");
|
|
963
|
+
void this.handleGatewaySessionBackendMessage(sessionId, raw);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
socket.on("close", (code, reason) => {
|
|
967
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
968
|
+
this.gatewaySessions.delete(sessionId);
|
|
969
|
+
if (gatewaySession.suppressCloseFrame) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (!gatewaySession.ready) {
|
|
973
|
+
if (gatewaySession.connectFailed) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
handlers.onConnectError(
|
|
977
|
+
buildGatewayError(
|
|
978
|
+
typeof reason === "string"
|
|
979
|
+
? reason
|
|
980
|
+
: reason?.toString("utf8") ?? "Local OpenClaw socket closed",
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
handlers.onClose({
|
|
986
|
+
code: sanitizeCloseCode(code, 1000),
|
|
987
|
+
reason:
|
|
988
|
+
typeof reason === "string"
|
|
989
|
+
? reason
|
|
990
|
+
: reason?.toString("utf8") ?? "Local socket closed",
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
socket.on("error", (error) => {
|
|
995
|
+
void daemonLogger.warn("local_socket_error", {
|
|
996
|
+
sessionId,
|
|
997
|
+
message: error instanceof Error ? error.message : String(error),
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async closeGatewaySession(sessionId) {
|
|
1003
|
+
if (!sessionId) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const gatewaySession = this.gatewaySessions.get(sessionId);
|
|
1007
|
+
if (!gatewaySession) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
this.gatewaySessions.delete(sessionId);
|
|
1011
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
1012
|
+
gatewaySession.suppressCloseFrame = true;
|
|
1013
|
+
try {
|
|
1014
|
+
gatewaySession.socket.close();
|
|
1015
|
+
} catch {
|
|
1016
|
+
// Ignore close failures.
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async closeGatewaySessionsByRouteKind(routeKind, reason = "Connection closed") {
|
|
1021
|
+
const matchingSessionIds = [];
|
|
1022
|
+
for (const [sessionId, gatewaySession] of this.gatewaySessions.entries()) {
|
|
1023
|
+
if (gatewaySession.routeKind === routeKind) {
|
|
1024
|
+
matchingSessionIds.push(sessionId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
for (const sessionId of matchingSessionIds) {
|
|
1029
|
+
const gatewaySession = this.gatewaySessions.get(sessionId);
|
|
1030
|
+
if (!gatewaySession) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
this.gatewaySessions.delete(sessionId);
|
|
1034
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
1035
|
+
gatewaySession.suppressCloseFrame = true;
|
|
1036
|
+
try {
|
|
1037
|
+
gatewaySession.socket.close(1001, reason.slice(0, 120));
|
|
1038
|
+
} catch {
|
|
1039
|
+
// Ignore close failures.
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
buildLocalConnectFrame(connectRequestId) {
|
|
1045
|
+
const auth =
|
|
1046
|
+
this.state.backend.authType === "password"
|
|
1047
|
+
? { password: this.state.backend.secret }
|
|
1048
|
+
: { token: this.state.backend.secret };
|
|
1049
|
+
|
|
1050
|
+
return JSON.stringify({
|
|
1051
|
+
type: "req",
|
|
1052
|
+
id: connectRequestId,
|
|
1053
|
+
method: "connect",
|
|
1054
|
+
params: {
|
|
1055
|
+
minProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
1056
|
+
maxProtocol: GATEWAY_PROTOCOL_VERSION,
|
|
1057
|
+
client: {
|
|
1058
|
+
id: "clawpilot-link",
|
|
1059
|
+
displayName: this.config.displayName || "ClawPilot Link",
|
|
1060
|
+
version: LINK_VERSION,
|
|
1061
|
+
platform: process.platform,
|
|
1062
|
+
mode: "backend",
|
|
1063
|
+
instanceId: this.config.installId,
|
|
1064
|
+
},
|
|
1065
|
+
caps: [...LOCAL_GATEWAY_CAPS],
|
|
1066
|
+
role: LOCAL_GATEWAY_ROLE,
|
|
1067
|
+
scopes: [...LOCAL_GATEWAY_SCOPES],
|
|
1068
|
+
auth,
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async handleGatewaySessionBackendMessage(sessionId, raw) {
|
|
1074
|
+
const gatewaySession = this.gatewaySessions.get(sessionId);
|
|
1075
|
+
if (!gatewaySession || !raw) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const parsed = parseGatewayFrame(raw);
|
|
1080
|
+
if (!gatewaySession.ready) {
|
|
1081
|
+
if (
|
|
1082
|
+
parsed &&
|
|
1083
|
+
parsed.type === "event" &&
|
|
1084
|
+
parsed.event === "connect.challenge" &&
|
|
1085
|
+
!gatewaySession.connectSent
|
|
1086
|
+
) {
|
|
1087
|
+
gatewaySession.connectSent = true;
|
|
1088
|
+
gatewaySession.connectRequestId = createRequestId("link-connect");
|
|
1089
|
+
gatewaySession.socket.send(
|
|
1090
|
+
this.buildLocalConnectFrame(gatewaySession.connectRequestId),
|
|
1091
|
+
);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (
|
|
1096
|
+
parsed &&
|
|
1097
|
+
parsed.type === "res" &&
|
|
1098
|
+
typeof parsed.id === "string" &&
|
|
1099
|
+
parsed.id === gatewaySession.connectRequestId
|
|
1100
|
+
) {
|
|
1101
|
+
if (parsed.ok) {
|
|
1102
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
1103
|
+
gatewaySession.ready = true;
|
|
1104
|
+
gatewaySession.handlers.onConnected(sanitizeHelloPayload(parsed.payload));
|
|
1105
|
+
const pendingMessages = [...gatewaySession.queue];
|
|
1106
|
+
gatewaySession.queue.length = 0;
|
|
1107
|
+
for (const pendingRaw of pendingMessages) {
|
|
1108
|
+
gatewaySession.socket.send(pendingRaw);
|
|
1109
|
+
}
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
clearTimeout(gatewaySession.connectTimer);
|
|
1114
|
+
gatewaySession.connectFailed = true;
|
|
1115
|
+
gatewaySession.handlers.onConnectError(
|
|
1116
|
+
parsed.error && typeof parsed.error === "object"
|
|
1117
|
+
? parsed.error
|
|
1118
|
+
: buildGatewayError("Local OpenClaw connect failed"),
|
|
1119
|
+
);
|
|
1120
|
+
await this.closeGatewaySession(sessionId);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
gatewaySession.handlers.onMessage(raw);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async sendGatewaySessionMessage(sessionId, raw) {
|
|
1131
|
+
if (!sessionId || !raw) {
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const gatewaySession = this.gatewaySessions.get(sessionId);
|
|
1136
|
+
if (!gatewaySession) {
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (gatewaySession.ready && gatewaySession.socket.readyState === WebSocket.OPEN) {
|
|
1141
|
+
gatewaySession.socket.send(raw);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
gatewaySession.queue.push(raw);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async forwardRelayAppWsMessage(payload) {
|
|
1149
|
+
const appConnId = normalizeNonEmptyString(payload.appConnId);
|
|
1150
|
+
const raw = typeof payload.data === "string" ? payload.data : null;
|
|
1151
|
+
if (!appConnId || !raw) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (!this.gatewaySessions.has(appConnId)) {
|
|
1156
|
+
await this.openGatewaySession(
|
|
1157
|
+
appConnId,
|
|
1158
|
+
this.buildRelayGatewaySessionHandlers(appConnId),
|
|
1159
|
+
{
|
|
1160
|
+
routeKind: "relay",
|
|
1161
|
+
},
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
await this.sendGatewaySessionMessage(appConnId, raw);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async proxyBackendHttpRequest(params) {
|
|
1169
|
+
if (!this.state.backend.supported || !this.state.backend.httpBaseUrl) {
|
|
1170
|
+
throw new Error(
|
|
1171
|
+
this.state.backend.message ?? "Current OpenClaw setup is not supported yet.",
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const requestUrl = `${this.state.backend.httpBaseUrl}${params.requestPath}`;
|
|
1176
|
+
const requestHeaders = {
|
|
1177
|
+
...(params.headers ?? {}),
|
|
1178
|
+
...buildLocalGatewayRequestHeaders(this.state.backend),
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
return await fetch(requestUrl, {
|
|
1182
|
+
method: normalizeNonEmptyString(params.method) ?? "GET",
|
|
1183
|
+
headers: requestHeaders,
|
|
1184
|
+
body: params.body ?? undefined,
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async handleRelayHttpRequest(payload) {
|
|
1189
|
+
const requestId = normalizeNonEmptyString(payload.requestId);
|
|
1190
|
+
const requestPath = normalizeNonEmptyString(payload.path);
|
|
1191
|
+
if (!requestId || !requestPath || !this.state.backend.httpBaseUrl) {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
try {
|
|
1196
|
+
const response = await this.proxyBackendHttpRequest({
|
|
1197
|
+
method: payload.method,
|
|
1198
|
+
requestPath,
|
|
1199
|
+
headers:
|
|
1200
|
+
payload.headers && typeof payload.headers === "object" ? payload.headers : {},
|
|
1201
|
+
body: payload.body ? fromBase64Url(payload.body) : undefined,
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
this.sendRelayFrame("http.response.start", {
|
|
1205
|
+
requestId,
|
|
1206
|
+
status: response.status,
|
|
1207
|
+
headers: collectResponseHeaders(response.headers),
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
if (response.body) {
|
|
1211
|
+
const reader = response.body.getReader();
|
|
1212
|
+
while (true) {
|
|
1213
|
+
const { value, done } = await reader.read();
|
|
1214
|
+
if (done) {
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
if (value && value.byteLength > 0) {
|
|
1218
|
+
this.sendRelayFrame("http.response.chunk", {
|
|
1219
|
+
requestId,
|
|
1220
|
+
chunk: toBase64Url(value),
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
this.sendRelayFrame("http.response.end", {
|
|
1227
|
+
requestId,
|
|
1228
|
+
});
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
this.sendRelayFrame("http.response.error", {
|
|
1231
|
+
requestId,
|
|
1232
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
isLocalDirectAccessKeyValid(token) {
|
|
1238
|
+
const expected = normalizeNonEmptyString(this.config.directAccessKey);
|
|
1239
|
+
const actual = normalizeNonEmptyString(token);
|
|
1240
|
+
return Boolean(expected && actual && expected === actual);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
describeDirectAccessListenFailure(error) {
|
|
1244
|
+
const code = normalizeNodeErrorCode(error);
|
|
1245
|
+
if (code === "EADDRINUSE") {
|
|
1246
|
+
return {
|
|
1247
|
+
reason: "port_in_use",
|
|
1248
|
+
message: this.t.t("localAccessPortInUse", {
|
|
1249
|
+
port: LINK_DIRECT_PORT,
|
|
1250
|
+
}),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
1254
|
+
return {
|
|
1255
|
+
reason: "permission_denied",
|
|
1256
|
+
message: this.t.t("localAccessPermissionDenied", {
|
|
1257
|
+
port: LINK_DIRECT_PORT,
|
|
1258
|
+
}),
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
reason: "listen_failed",
|
|
1263
|
+
message: error instanceof Error ? error.message : this.t.t("localAccessUnavailable"),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async openLocalGatewayServer() {
|
|
1268
|
+
if (this.localGatewayServer) {
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const wsServer = new WebSocketServer({ noServer: true });
|
|
1273
|
+
wsServer.on("connection", (socket) => {
|
|
1274
|
+
const sessionId = createRequestId("lan");
|
|
1275
|
+
const appSocketState = {
|
|
1276
|
+
socket,
|
|
1277
|
+
sessionId,
|
|
1278
|
+
authenticated: false,
|
|
1279
|
+
connectRequestId: null,
|
|
1280
|
+
authTimer: setTimeout(() => {
|
|
1281
|
+
if (appSocketState.authenticated) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
socket.close(1008, "Link connect timed out");
|
|
1286
|
+
} catch {
|
|
1287
|
+
// Ignore close failures.
|
|
1288
|
+
}
|
|
1289
|
+
}, LOCAL_APP_CONNECT_TIMEOUT_MS),
|
|
1290
|
+
};
|
|
1291
|
+
this.localAppSockets.set(sessionId, appSocketState);
|
|
1292
|
+
|
|
1293
|
+
socket.on("message", (rawData) => {
|
|
1294
|
+
void this.handleLocalGatewayWebSocketMessage(sessionId, rawData);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
socket.on("close", () => {
|
|
1298
|
+
clearTimeout(appSocketState.authTimer);
|
|
1299
|
+
this.localAppSockets.delete(sessionId);
|
|
1300
|
+
void this.closeGatewaySession(sessionId);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
socket.on("error", (error) => {
|
|
1304
|
+
void daemonLogger.warn("local_app_socket_error", {
|
|
1305
|
+
sessionId,
|
|
1306
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
socket.send(JSON.stringify({
|
|
1311
|
+
type: "event",
|
|
1312
|
+
event: "connect.challenge",
|
|
1313
|
+
payload: {
|
|
1314
|
+
nonce: createNonce(),
|
|
1315
|
+
},
|
|
1316
|
+
}));
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
const server = http.createServer((request, response) => {
|
|
1320
|
+
void this.handleLocalGatewayHttpRequest(request, response);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
server.on("upgrade", (request, socket, head) => {
|
|
1324
|
+
const requestPath = normalizeRequestPath(request.url);
|
|
1325
|
+
if (requestPath !== "/" && requestPath !== "/ws") {
|
|
1326
|
+
socket.destroy();
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
wsServer.handleUpgrade(request, socket, head, (upgradedSocket) => {
|
|
1331
|
+
wsServer.emit("connection", upgradedSocket, request);
|
|
1332
|
+
});
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
try {
|
|
1336
|
+
await new Promise((resolve, reject) => {
|
|
1337
|
+
server.once("error", reject);
|
|
1338
|
+
server.listen(LINK_DIRECT_PORT, "0.0.0.0", () => {
|
|
1339
|
+
server.off("error", reject);
|
|
1340
|
+
resolve();
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
this.localGatewayServer = server;
|
|
1344
|
+
this.localGatewayWsServer = wsServer;
|
|
1345
|
+
this.state = await patchState({
|
|
1346
|
+
directAccess: {
|
|
1347
|
+
status: "listening",
|
|
1348
|
+
port: LINK_DIRECT_PORT,
|
|
1349
|
+
reason: null,
|
|
1350
|
+
message: this.t.t("localAccessReadyOnPort", {
|
|
1351
|
+
port: LINK_DIRECT_PORT,
|
|
1352
|
+
}),
|
|
1353
|
+
checkedAt: new Date().toISOString(),
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
await daemonLogger.info("local_gateway_server_listening", {
|
|
1357
|
+
port: LINK_DIRECT_PORT,
|
|
1358
|
+
});
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
this.localGatewayServer = null;
|
|
1361
|
+
this.localGatewayWsServer = null;
|
|
1362
|
+
wsServer.close();
|
|
1363
|
+
server.close();
|
|
1364
|
+
const directAccessFailure = this.describeDirectAccessListenFailure(error);
|
|
1365
|
+
const shouldReportUnavailableState =
|
|
1366
|
+
this.state.directAccess?.status !== "unavailable" ||
|
|
1367
|
+
this.state.directAccess?.reason !== directAccessFailure.reason;
|
|
1368
|
+
if (shouldReportUnavailableState) {
|
|
1369
|
+
this.state = await patchState({
|
|
1370
|
+
directAccess: {
|
|
1371
|
+
status: "unavailable",
|
|
1372
|
+
port: LINK_DIRECT_PORT,
|
|
1373
|
+
reason: directAccessFailure.reason,
|
|
1374
|
+
message: directAccessFailure.message,
|
|
1375
|
+
checkedAt: new Date().toISOString(),
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
await daemonLogger.warn("local_gateway_server_unavailable", {
|
|
1379
|
+
port: LINK_DIRECT_PORT,
|
|
1380
|
+
reason: directAccessFailure.reason,
|
|
1381
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
writeLocalGatewayJson(response, statusCode, payload) {
|
|
1388
|
+
const body = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
|
|
1389
|
+
response.writeHead(statusCode, {
|
|
1390
|
+
"content-type": "application/json; charset=utf-8",
|
|
1391
|
+
"content-length": String(body.byteLength),
|
|
1392
|
+
"cache-control": "no-store",
|
|
1393
|
+
});
|
|
1394
|
+
response.end(body);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async handleLocalGatewayHttpRequest(request, response) {
|
|
1398
|
+
const requestPath = normalizeRequestPath(request.url);
|
|
1399
|
+
const bearerToken = extractBearerToken(request.headers.authorization);
|
|
1400
|
+
|
|
1401
|
+
if (!this.isLocalDirectAccessKeyValid(bearerToken)) {
|
|
1402
|
+
this.writeLocalGatewayJson(response, 401, {
|
|
1403
|
+
error: {
|
|
1404
|
+
message: "Scan the ClawPilot Link QR code again to refresh local access.",
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (requestPath === "/healthz") {
|
|
1411
|
+
const statusCode = this.state.backend.supported ? 200 : 503;
|
|
1412
|
+
this.writeLocalGatewayJson(response, statusCode, {
|
|
1413
|
+
ok: this.state.backend.supported,
|
|
1414
|
+
connectionStatus: this.state.connectionStatus,
|
|
1415
|
+
linkId: this.credentials.linkId,
|
|
1416
|
+
backend: {
|
|
1417
|
+
detected: this.state.backend.detected,
|
|
1418
|
+
supported: this.state.backend.supported,
|
|
1419
|
+
message: this.state.backend.message,
|
|
1420
|
+
},
|
|
1421
|
+
});
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
try {
|
|
1426
|
+
const body = await readIncomingRequestBody(request);
|
|
1427
|
+
const proxyResponse = await this.proxyBackendHttpRequest({
|
|
1428
|
+
method: request.method,
|
|
1429
|
+
requestPath,
|
|
1430
|
+
headers: collectIncomingRequestHeaders(request.headers),
|
|
1431
|
+
body,
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
response.writeHead(proxyResponse.status, collectResponseHeaders(proxyResponse.headers));
|
|
1435
|
+
if (!proxyResponse.body) {
|
|
1436
|
+
response.end();
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const reader = proxyResponse.body.getReader();
|
|
1441
|
+
while (true) {
|
|
1442
|
+
const { value, done } = await reader.read();
|
|
1443
|
+
if (done) {
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
if (value && value.byteLength > 0) {
|
|
1447
|
+
response.write(Buffer.from(value));
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
response.end();
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
this.writeLocalGatewayJson(response, 502, {
|
|
1453
|
+
error: {
|
|
1454
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1455
|
+
},
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async handleLocalGatewayWebSocketMessage(sessionId, rawData) {
|
|
1461
|
+
const appSocketState = this.localAppSockets.get(sessionId);
|
|
1462
|
+
if (!appSocketState) {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const raw = typeof rawData === "string" ? rawData : rawData.toString("utf8");
|
|
1467
|
+
if (!raw) {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (!appSocketState.authenticated) {
|
|
1472
|
+
const parsed = parseGatewayFrame(raw);
|
|
1473
|
+
if (
|
|
1474
|
+
!parsed ||
|
|
1475
|
+
parsed.type !== "req" ||
|
|
1476
|
+
parsed.method !== "connect" ||
|
|
1477
|
+
typeof parsed.id !== "string"
|
|
1478
|
+
) {
|
|
1479
|
+
try {
|
|
1480
|
+
appSocketState.socket.close(1008, "Link connect is required first");
|
|
1481
|
+
} catch {
|
|
1482
|
+
// Ignore close failures.
|
|
1483
|
+
}
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
appSocketState.connectRequestId = parsed.id;
|
|
1488
|
+
const token = normalizeNonEmptyString(parsed.params?.auth?.token);
|
|
1489
|
+
if (!this.isLocalDirectAccessKeyValid(token)) {
|
|
1490
|
+
appSocketState.socket.send(JSON.stringify({
|
|
1491
|
+
type: "res",
|
|
1492
|
+
id: parsed.id,
|
|
1493
|
+
ok: false,
|
|
1494
|
+
error: buildGatewayError(
|
|
1495
|
+
"This local Link code is no longer valid. Scan the QR code again.",
|
|
1496
|
+
"unauthorized",
|
|
1497
|
+
),
|
|
1498
|
+
}));
|
|
1499
|
+
try {
|
|
1500
|
+
appSocketState.socket.close(1008, "Local Link auth failed");
|
|
1501
|
+
} catch {
|
|
1502
|
+
// Ignore close failures.
|
|
1503
|
+
}
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (!this.state.backend.supported) {
|
|
1508
|
+
appSocketState.socket.send(JSON.stringify({
|
|
1509
|
+
type: "res",
|
|
1510
|
+
id: parsed.id,
|
|
1511
|
+
ok: false,
|
|
1512
|
+
error: buildGatewayError(
|
|
1513
|
+
this.state.backend.message ?? "OpenClaw is not available on this computer.",
|
|
1514
|
+
),
|
|
1515
|
+
}));
|
|
1516
|
+
try {
|
|
1517
|
+
appSocketState.socket.close(1011, "OpenClaw unavailable");
|
|
1518
|
+
} catch {
|
|
1519
|
+
// Ignore close failures.
|
|
1520
|
+
}
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
await this.openGatewaySession(
|
|
1525
|
+
sessionId,
|
|
1526
|
+
{
|
|
1527
|
+
onConnected: (hello) => {
|
|
1528
|
+
appSocketState.authenticated = true;
|
|
1529
|
+
clearTimeout(appSocketState.authTimer);
|
|
1530
|
+
appSocketState.socket.send(JSON.stringify({
|
|
1531
|
+
type: "res",
|
|
1532
|
+
id: parsed.id,
|
|
1533
|
+
ok: true,
|
|
1534
|
+
payload: hello,
|
|
1535
|
+
}));
|
|
1536
|
+
},
|
|
1537
|
+
onMessage: (message) => {
|
|
1538
|
+
appSocketState.socket.send(message);
|
|
1539
|
+
},
|
|
1540
|
+
onConnectError: (error) => {
|
|
1541
|
+
appSocketState.socket.send(JSON.stringify({
|
|
1542
|
+
type: "res",
|
|
1543
|
+
id: parsed.id,
|
|
1544
|
+
ok: false,
|
|
1545
|
+
error,
|
|
1546
|
+
}));
|
|
1547
|
+
try {
|
|
1548
|
+
appSocketState.socket.close(1011, normalizeNonEmptyString(error?.message) ?? "Connect failed");
|
|
1549
|
+
} catch {
|
|
1550
|
+
// Ignore close failures.
|
|
1551
|
+
}
|
|
1552
|
+
},
|
|
1553
|
+
onClose: ({ code, reason }) => {
|
|
1554
|
+
try {
|
|
1555
|
+
appSocketState.socket.close(sanitizeCloseCode(code), reason || "Gateway closed");
|
|
1556
|
+
} catch {
|
|
1557
|
+
// Ignore close failures.
|
|
1558
|
+
}
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
routeKind: "lan",
|
|
1563
|
+
},
|
|
1564
|
+
);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
await this.sendGatewaySessionMessage(sessionId, raw);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async openIpcServer() {
|
|
1572
|
+
if (process.platform !== "win32") {
|
|
1573
|
+
try {
|
|
1574
|
+
await import("node:fs/promises").then(({ unlink }) => unlink(runtimePaths.socketFile));
|
|
1575
|
+
} catch {
|
|
1576
|
+
// Ignore stale socket cleanup failures.
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
this.server = net.createServer((socket) => {
|
|
1581
|
+
let buffer = "";
|
|
1582
|
+
socket.on("data", (chunk) => {
|
|
1583
|
+
buffer += chunk.toString("utf8");
|
|
1584
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
1585
|
+
while (newlineIndex >= 0) {
|
|
1586
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
1587
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
1588
|
+
if (line) {
|
|
1589
|
+
void this.handleIpcLine(socket, line);
|
|
1590
|
+
}
|
|
1591
|
+
newlineIndex = buffer.indexOf("\n");
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
await new Promise((resolve, reject) => {
|
|
1597
|
+
this.server.once("error", reject);
|
|
1598
|
+
this.server.listen(runtimePaths.socketFile, () => {
|
|
1599
|
+
this.server.off("error", reject);
|
|
1600
|
+
resolve();
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async handleIpcLine(socket, line) {
|
|
1606
|
+
const message = safeJsonParse(line);
|
|
1607
|
+
if (!message?.id || typeof message.method !== "string") {
|
|
1608
|
+
socket.write(`${JSON.stringify({
|
|
1609
|
+
id: message?.id ?? null,
|
|
1610
|
+
ok: false,
|
|
1611
|
+
error: { message: "invalid_ipc_message" },
|
|
1612
|
+
})}\n`);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
try {
|
|
1617
|
+
const result = await this.handleIpcRequest(message.method, message.params ?? {});
|
|
1618
|
+
socket.write(`${JSON.stringify({
|
|
1619
|
+
id: message.id,
|
|
1620
|
+
ok: true,
|
|
1621
|
+
result,
|
|
1622
|
+
})}\n`);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
socket.write(`${JSON.stringify({
|
|
1625
|
+
id: message.id,
|
|
1626
|
+
ok: false,
|
|
1627
|
+
error: {
|
|
1628
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1629
|
+
},
|
|
1630
|
+
})}\n`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
async handleIpcRequest(method) {
|
|
1635
|
+
switch (method) {
|
|
1636
|
+
case "status.get":
|
|
1637
|
+
return {
|
|
1638
|
+
config: this.config,
|
|
1639
|
+
state: this.state,
|
|
1640
|
+
credentials: {
|
|
1641
|
+
linkId: this.credentials.linkId,
|
|
1642
|
+
refreshTokenExpiresAt: this.credentials.refreshTokenExpiresAt,
|
|
1643
|
+
accessTokenExpiresAt: this.credentials.accessTokenExpiresAt,
|
|
1644
|
+
},
|
|
1645
|
+
relayConnected: this.relayConnected,
|
|
1646
|
+
};
|
|
1647
|
+
case "daemon.reconnect":
|
|
1648
|
+
await this.ensureRelayConnection(true);
|
|
1649
|
+
return { ok: true };
|
|
1650
|
+
case "daemon.shutdown":
|
|
1651
|
+
await this.stop();
|
|
1652
|
+
return { ok: true };
|
|
1653
|
+
case "daemon.clearCredentials":
|
|
1654
|
+
await clearCredentials();
|
|
1655
|
+
this.credentials = await loadCredentials();
|
|
1656
|
+
return { ok: true };
|
|
1657
|
+
default:
|
|
1658
|
+
throw new Error("unknown_ipc_method");
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|