@matelink/cli 2026.4.7

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.
Files changed (3) hide show
  1. package/README.md +180 -0
  2. package/bin/matecli.mjs +3093 -0
  3. package/package.json +20 -0
@@ -0,0 +1,3093 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ randomBytes,
5
+ randomUUID,
6
+ generateKeyPairSync,
7
+ createPrivateKey,
8
+ createPublicKey,
9
+ createHash,
10
+ sign,
11
+ } from "node:crypto";
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const NUMERIC_CODE_LENGTH = 4;
20
+ const GROUP_SIZE = 4;
21
+ const DEFAULT_RELAY_WORKER_WAIT_SECONDS = 600;
22
+ const DEFAULT_RELAY_URL = "http://43.134.64.199:8090";
23
+ const DEFAULT_GATEWAY_HOST = "127.0.0.1";
24
+ const DEFAULT_WEBHOOK_PATH = "/testnextim/webhook";
25
+ const DEFAULT_BIND_PATH = "/testnextim/bind";
26
+ const GATEWAY_RPC_CLI_TIMEOUT_MS = 20000;
27
+ const CLI_PACKAGE_NAME = "@matelink/cli";
28
+ const CLI_COMMAND_NAME = "matecli";
29
+ // Relay worker defaults to full operator scope so gateway HTTP routes and RPC-adjacent
30
+ // compatibility endpoints remain available without per-method scope juggling.
31
+ const DEFAULT_GATEWAY_SCOPES = "operator.admin";
32
+ const CLI_ENTRY = fileURLToPath(import.meta.url);
33
+
34
+ function printHelp() {
35
+ console.log(
36
+ [
37
+ `${CLI_COMMAND_NAME} CLI`,
38
+ "",
39
+ "Usage:",
40
+ ` ${CLI_COMMAND_NAME} setup [--relay <url>] [--public-base <url>] [--mode <relay>] [--account <id>] [--restart|--no-restart] [--json]`,
41
+ ` ${CLI_COMMAND_NAME} pair <1234|123456|XXXX-XXXX> [--mode <relay>]`,
42
+ ` ${CLI_COMMAND_NAME} reset [--relay <url>] [--gateway <url>] [--json]`,
43
+ ` ${CLI_COMMAND_NAME} bridge [--relay <url>] [--gateway <url>] [--json] (Deprecated)`,
44
+ ` ${CLI_COMMAND_NAME} pair-url [<1234|123456|XXXX-XXXX>] [--account <id>] [--code <1234|123456|XXXX-XXXX>] [--json]`,
45
+ ` ${CLI_COMMAND_NAME} status [--json]`,
46
+ "",
47
+ `Package: ${CLI_PACKAGE_NAME}`,
48
+ "",
49
+ "Environment:",
50
+ " OPENCLAW_HOME Override OpenClaw home directory (default: ~/.openclaw)",
51
+ " OPENIM_RELAY_URL / OPENIM_RELAY_TOKEN",
52
+ ` TESTNEXTIM_RELAY_URL Override relay base URL (default: ${DEFAULT_RELAY_URL})`,
53
+ " TESTNEXTIM_RELAY_TOKEN Bearer token used for pair publish/bind endpoints",
54
+ " OPENCLAW_TESTNEXTIM_GATEWAY_BASE_URL Local gateway base (overrides openclaw.json gateway.port)",
55
+ " OPENCLAW_GATEWAY_PORT / OPENCLAW_GATEWAY_HOST Optional explicit local gateway host/port override",
56
+ "",
57
+ "Tip:",
58
+ " pair command will auto-start relay bridge worker in detached mode.",
59
+ " Use --no-serve-relay if you do not want auto bridge worker.",
60
+ ].join("\n"),
61
+ );
62
+ }
63
+
64
+ function fail(message, code = 1) {
65
+ console.error(`${CLI_COMMAND_NAME}: ${message}`);
66
+ process.exit(code);
67
+ }
68
+
69
+ function parseArgs(argv) {
70
+ const args = [...argv];
71
+ const command = args.shift() ?? "help";
72
+ const options = {
73
+ account: undefined,
74
+ json: false,
75
+ relay: undefined,
76
+ noRelay: false,
77
+ code: undefined,
78
+ gateway: undefined,
79
+ publicBase: undefined,
80
+ restart: true,
81
+ waitBind: false,
82
+ waitSeconds: DEFAULT_RELAY_WORKER_WAIT_SECONDS,
83
+ serveRelay: true,
84
+ mode: undefined,
85
+ };
86
+
87
+ while (args.length > 0) {
88
+ const token = args.shift();
89
+ if (!token) break;
90
+
91
+ if (token === "--json") {
92
+ options.json = true;
93
+ continue;
94
+ }
95
+ if (token === "--no-relay") {
96
+ options.noRelay = true;
97
+ continue;
98
+ }
99
+ if (token === "--wait-bind") {
100
+ options.waitBind = true;
101
+ continue;
102
+ }
103
+ if (token === "--no-wait-bind") {
104
+ options.waitBind = false;
105
+ continue;
106
+ }
107
+ if (token === "--serve-relay") {
108
+ options.serveRelay = true;
109
+ continue;
110
+ }
111
+ if (token === "--no-serve-relay") {
112
+ options.serveRelay = false;
113
+ continue;
114
+ }
115
+
116
+ if (token === "--relay") {
117
+ const value = args.shift();
118
+ if (!value || value.startsWith("--")) fail("missing value for --relay");
119
+ options.relay = value.trim();
120
+ continue;
121
+ }
122
+ if (token === "--gateway") {
123
+ const value = args.shift();
124
+ if (!value || value.startsWith("--")) fail("missing value for --gateway");
125
+ options.gateway = value.trim();
126
+ continue;
127
+ }
128
+ if (token === "--public-base") {
129
+ const value = args.shift();
130
+ if (!value || value.startsWith("--")) fail("missing value for --public-base");
131
+ options.publicBase = value.trim();
132
+ continue;
133
+ }
134
+ if (token === "--wait-seconds") {
135
+ const value = args.shift();
136
+ if (!value || value.startsWith("--")) fail("missing value for --wait-seconds");
137
+ const parsed = Number.parseInt(value.trim(), 10);
138
+ if (!Number.isFinite(parsed) || parsed < 0) fail("--wait-seconds must be >= 0");
139
+ options.waitSeconds = Math.min(parsed, 3600);
140
+ continue;
141
+ }
142
+ if (token === "--restart") {
143
+ options.restart = true;
144
+ continue;
145
+ }
146
+ if (token === "--no-restart") {
147
+ options.restart = false;
148
+ continue;
149
+ }
150
+ if (token === "--account") {
151
+ const value = args.shift();
152
+ if (!value || value.startsWith("--")) fail("missing value for --account");
153
+ options.account = value.trim();
154
+ continue;
155
+ }
156
+ if (token === "--mode") {
157
+ const value = args.shift();
158
+ if (!value || value.startsWith("--")) fail("missing value for --mode");
159
+ const normalizedMode = normalizeChatTransportMode(value.trim(), "__invalid__");
160
+ if (normalizedMode !== "relay") {
161
+ fail("--mode must be relay");
162
+ }
163
+ options.mode = normalizedMode;
164
+ continue;
165
+ }
166
+ if (token === "--code") {
167
+ const value = args.shift();
168
+ if (!value || value.startsWith("--")) fail("missing value for --code");
169
+ options.code = value.trim();
170
+ continue;
171
+ }
172
+
173
+ if (token === "-h" || token === "--help" || token === "help") {
174
+ return { command: "help", options };
175
+ }
176
+ if (token.startsWith("--")) {
177
+ fail(`unknown option: ${token}`);
178
+ }
179
+
180
+ if (command === "pair" || command === "pair-url") {
181
+ if (!options.code) {
182
+ options.code = token.trim();
183
+ continue;
184
+ }
185
+ if (!options.account) {
186
+ options.account = token.trim();
187
+ continue;
188
+ }
189
+ fail(`unexpected argument: ${token}`);
190
+ }
191
+
192
+ fail(`unknown option: ${token}`);
193
+ }
194
+
195
+ return { command, options };
196
+ }
197
+
198
+ function resolveOpenClawHome() {
199
+ return process.env.OPENCLAW_HOME?.trim() || path.join(os.homedir(), ".openclaw");
200
+ }
201
+
202
+ function resolveOpenClawConfigPath() {
203
+ return path.join(resolveOpenClawHome(), "openclaw.json");
204
+ }
205
+
206
+ function resolveTestNextIMStatePath() {
207
+ return path.join(resolveOpenClawHome(), "testnextim.state.json");
208
+ }
209
+
210
+ function resolveBridgeLockPath({ relayUrl, gatewayId, gatewayBaseUrl }) {
211
+ const digest = createHash("sha256")
212
+ .update(
213
+ [
214
+ String(relayUrl ?? "").trim(),
215
+ String(gatewayId ?? "").trim(),
216
+ String(gatewayBaseUrl ?? "").trim(),
217
+ ].join("|"),
218
+ )
219
+ .digest("hex")
220
+ .slice(0, 24);
221
+ return path.join(resolveOpenClawHome(), "locks", `testnextim-bridge-${digest}.lock`);
222
+ }
223
+
224
+ function readJsonFile(filePath) {
225
+ let content;
226
+ try {
227
+ content = fs.readFileSync(filePath, "utf8");
228
+ } catch {
229
+ fail(`cannot read config file: ${filePath}`);
230
+ }
231
+ try {
232
+ return JSON.parse(content);
233
+ } catch {
234
+ fail(`invalid JSON in config file: ${filePath}`);
235
+ }
236
+ }
237
+
238
+ function writeJsonFile(filePath, value) {
239
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
240
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
241
+ }
242
+
243
+ function readOptionalJsonFile(filePath) {
244
+ try {
245
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
246
+ } catch {
247
+ return {};
248
+ }
249
+ }
250
+
251
+ function isPidAlive(pid) {
252
+ const normalized = Number(pid);
253
+ if (!Number.isFinite(normalized) || normalized <= 0) {
254
+ return false;
255
+ }
256
+ try {
257
+ process.kill(normalized, 0);
258
+ return true;
259
+ } catch (error) {
260
+ if (error && typeof error === "object" && error.code === "EPERM") {
261
+ return true;
262
+ }
263
+ return false;
264
+ }
265
+ }
266
+
267
+ function acquireBridgeLock({
268
+ relayUrl,
269
+ gatewayId,
270
+ gatewayBaseUrl,
271
+ }) {
272
+ const lockPath = resolveBridgeLockPath({ relayUrl, gatewayId, gatewayBaseUrl });
273
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
274
+
275
+ const meta = {
276
+ pid: process.pid,
277
+ relayUrl,
278
+ gatewayId,
279
+ gatewayBaseUrl,
280
+ startedAt: new Date().toISOString(),
281
+ };
282
+
283
+ const writeLockFile = () => {
284
+ const fd = fs.openSync(lockPath, "wx");
285
+ fs.writeFileSync(fd, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
286
+ let released = false;
287
+ const release = () => {
288
+ if (released) {
289
+ return;
290
+ }
291
+ released = true;
292
+ try {
293
+ const current = ensureObject(readOptionalJsonFile(lockPath));
294
+ if (Number(current.pid) === process.pid) {
295
+ fs.unlinkSync(lockPath);
296
+ }
297
+ } catch {}
298
+ try {
299
+ fs.closeSync(fd);
300
+ } catch {}
301
+ };
302
+ return {
303
+ acquired: true,
304
+ lockPath,
305
+ release,
306
+ };
307
+ };
308
+
309
+ try {
310
+ return writeLockFile();
311
+ } catch (error) {
312
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") {
313
+ throw error;
314
+ }
315
+ }
316
+
317
+ const existing = ensureObject(readOptionalJsonFile(lockPath));
318
+ if (isPidAlive(existing.pid)) {
319
+ return {
320
+ acquired: false,
321
+ lockPath,
322
+ ownerPid: Number(existing.pid) || null,
323
+ };
324
+ }
325
+
326
+ try {
327
+ fs.unlinkSync(lockPath);
328
+ } catch {}
329
+
330
+ try {
331
+ return writeLockFile();
332
+ } catch (error) {
333
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") {
334
+ throw error;
335
+ }
336
+ const current = ensureObject(readOptionalJsonFile(lockPath));
337
+ return {
338
+ acquired: false,
339
+ lockPath,
340
+ ownerPid: Number(current.pid) || null,
341
+ };
342
+ }
343
+ }
344
+
345
+ function randomHex(bytesLength = 16) {
346
+ return randomBytes(bytesLength).toString("hex");
347
+ }
348
+
349
+ function normalizeAccountId(accountId) {
350
+ const value = (accountId ?? "").trim();
351
+ if (!value) return "default";
352
+ return value.toLowerCase();
353
+ }
354
+
355
+ function ensureObject(value) {
356
+ if (value && typeof value === "object" && !Array.isArray(value)) {
357
+ return { ...value };
358
+ }
359
+ return {};
360
+ }
361
+
362
+ function ensureArray(value) {
363
+ if (Array.isArray(value)) {
364
+ return [...value];
365
+ }
366
+ return [];
367
+ }
368
+
369
+ function normalizeUserId(value) {
370
+ return String(value ?? "").trim().toLowerCase();
371
+ }
372
+
373
+ function formatCode(raw) {
374
+ return `${raw.slice(0, GROUP_SIZE)}-${raw.slice(GROUP_SIZE)}`;
375
+ }
376
+
377
+ function normalizeProvidedCode(raw) {
378
+ const compact = String(raw ?? "")
379
+ .trim()
380
+ .toUpperCase()
381
+ .replace(/\s+/g, "")
382
+ .replace(/-/g, "");
383
+ if (/^\d{4}$/.test(compact)) return compact;
384
+ if (/^\d{6}$/.test(compact)) return compact;
385
+ if (/^[A-Z0-9]{8}$/.test(compact)) return formatCode(compact);
386
+ fail("invalid --code format. expected 1234, 123456 or XXXX-XXXX");
387
+ }
388
+
389
+ function generatePairCode() {
390
+ // Default one-click code is short numeric for terminal typing.
391
+ const min = 10 ** (NUMERIC_CODE_LENGTH - 1);
392
+ const max = 10 ** NUMERIC_CODE_LENGTH;
393
+ return String(min + Math.floor(Math.random() * (max - min)));
394
+ }
395
+
396
+ function readChannelSection(config) {
397
+ const state = readOptionalJsonFile(resolveTestNextIMStatePath());
398
+ const channels = config?.channels;
399
+ const legacy =
400
+ channels && typeof channels === "object"
401
+ ? channels.testnextim ?? channels["custom-im"] ?? {}
402
+ : {};
403
+ return {
404
+ ...ensureObject(legacy),
405
+ ...ensureObject(state),
406
+ };
407
+ }
408
+
409
+ function parseNonNegativeInt(value, fallback = 0) {
410
+ if (typeof value === "number" && Number.isFinite(value)) {
411
+ return value >= 0 ? Math.trunc(value) : fallback;
412
+ }
413
+ if (typeof value === "string" && value.trim()) {
414
+ const parsed = Number.parseInt(value.trim(), 10);
415
+ if (Number.isFinite(parsed) && parsed >= 0) {
416
+ return parsed;
417
+ }
418
+ }
419
+ return fallback;
420
+ }
421
+
422
+
423
+ function normalizePath(input, fallback) {
424
+ const value = typeof input === "string" ? input.trim() : "";
425
+ if (!value) return fallback;
426
+ return value.startsWith("/") ? value : `/${value}`;
427
+ }
428
+
429
+ function normalizeChatTransportMode(raw, fallback = "relay") {
430
+ const value = String(raw ?? "")
431
+ .trim()
432
+ .toLowerCase();
433
+ if (value === "relay") return "relay";
434
+ return fallback;
435
+ }
436
+
437
+ function trimSlash(input) {
438
+ return input.replace(/\/+$/, "");
439
+ }
440
+
441
+ function normalizeBaseUrl(input) {
442
+ const value = typeof input === "string" ? input.trim() : "";
443
+ if (!value) return null;
444
+ return trimSlash(value);
445
+ }
446
+
447
+ function buildBindUrl({ base, bindPath, accountId, code }) {
448
+ if (!base || typeof base !== "string" || !base.trim()) return null;
449
+ const normalizedBase = trimSlash(base.trim());
450
+ const normalizedPath = normalizePath(bindPath, DEFAULT_BIND_PATH);
451
+ const qp = new URLSearchParams({ accountId, code });
452
+ return `${normalizedBase}${normalizedPath}?${qp.toString()}`;
453
+ }
454
+
455
+ function buildGatewayResponsesUrl(base) {
456
+ const normalized = normalizeBaseUrl(base);
457
+ if (!normalized) fail("gateway base URL is required");
458
+ let parsed;
459
+ try {
460
+ parsed = new URL(normalized);
461
+ } catch {
462
+ fail(`invalid gateway base URL: ${base}`);
463
+ }
464
+ parsed.pathname = "/v1/responses";
465
+ parsed.search = "";
466
+ parsed.hash = "";
467
+ return parsed.toString();
468
+ }
469
+
470
+ function buildGatewayWsUrl(base) {
471
+ const normalized = normalizeBaseUrl(base);
472
+ if (!normalized) fail("gateway base URL is required");
473
+ let parsed;
474
+ try {
475
+ parsed = new URL(normalized);
476
+ } catch {
477
+ fail(`invalid gateway base URL: ${base}`);
478
+ }
479
+ if (parsed.protocol === "https:") {
480
+ parsed.protocol = "wss:";
481
+ } else {
482
+ parsed.protocol = "ws:";
483
+ }
484
+ parsed.pathname = "/";
485
+ parsed.search = "";
486
+ parsed.hash = "";
487
+ return parsed.toString();
488
+ }
489
+
490
+ function resolveAppSecret(section) {
491
+ return (
492
+ (typeof section.appSecret === "string" ? section.appSecret.trim() : "") ||
493
+ process.env.TESTNEXTIM_APP_SECRET?.trim() ||
494
+ process.env.CUSTOM_IM_APP_SECRET?.trim() ||
495
+ ""
496
+ );
497
+ }
498
+
499
+ function ensureManualPairSafety(section) {
500
+ const appSecret = resolveAppSecret(section);
501
+ if (!appSecret) {
502
+ fail(
503
+ [
504
+ "manual pair-code mode requires testnextim appSecret.",
505
+ "Run:",
506
+ ` export TESTNEXTIM_APP_SECRET="<your-secret>"`,
507
+ ].join("\n"),
508
+ );
509
+ }
510
+ }
511
+
512
+ function resolveRelayUrl(options, section) {
513
+ if (options.noRelay) return null;
514
+ const fromOption = normalizeBaseUrl(options.relay);
515
+ if (fromOption) return fromOption;
516
+ const fromEnv =
517
+ normalizeBaseUrl(process.env.OPENIM_RELAY_URL) ||
518
+ normalizeBaseUrl(process.env.TESTNEXTIM_RELAY_URL);
519
+ if (fromEnv) return fromEnv;
520
+ return DEFAULT_RELAY_URL;
521
+ }
522
+
523
+ function parseGatewayPortEnvValue(raw) {
524
+ const trimmed = String(raw ?? "").trim();
525
+ if (!trimmed) {
526
+ return null;
527
+ }
528
+ if (/^\d+$/.test(trimmed)) {
529
+ const parsed = Number.parseInt(trimmed, 10);
530
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
531
+ }
532
+ const bracketedIpv6Match = trimmed.match(/^\[[^\]]+\]:(\d+)$/);
533
+ if (bracketedIpv6Match?.[1]) {
534
+ const parsed = Number.parseInt(bracketedIpv6Match[1], 10);
535
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
536
+ }
537
+ const firstColon = trimmed.indexOf(":");
538
+ const lastColon = trimmed.lastIndexOf(":");
539
+ if (firstColon <= 0 || firstColon !== lastColon) {
540
+ return null;
541
+ }
542
+ const suffix = trimmed.slice(firstColon + 1);
543
+ if (!/^\d+$/.test(suffix)) {
544
+ return null;
545
+ }
546
+ const parsed = Number.parseInt(suffix, 10);
547
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
548
+ }
549
+
550
+ function extractPortFromText(raw) {
551
+ const text = String(raw ?? "").trim();
552
+ if (!text) return null;
553
+ const direct = parseGatewayPortEnvValue(text);
554
+ if (direct != null) return direct;
555
+ const matches = text.match(/\b\d{2,5}\b/g) ?? [];
556
+ for (let i = matches.length - 1; i >= 0; i -= 1) {
557
+ const parsed = parseGatewayPortEnvValue(matches[i]);
558
+ if (parsed != null) return parsed;
559
+ }
560
+ return null;
561
+ }
562
+
563
+ function readGatewayPortViaOpenClawCli() {
564
+ const result = spawnSync("openclaw", ["config", "get", "gateway.port"], {
565
+ encoding: "utf8",
566
+ });
567
+ if (result.status !== 0) {
568
+ return null;
569
+ }
570
+ return extractPortFromText(result.stdout);
571
+ }
572
+
573
+ function formatHostForUrl(host) {
574
+ const value = String(host ?? "").trim();
575
+ if (!value) return "";
576
+ if (value.startsWith("[") && value.endsWith("]")) {
577
+ return value;
578
+ }
579
+ if (value.includes(":")) {
580
+ return `[${value}]`;
581
+ }
582
+ return value;
583
+ }
584
+
585
+ function resolveGatewayPort(config) {
586
+ const fromEnv = parseGatewayPortEnvValue(process.env.OPENCLAW_GATEWAY_PORT);
587
+ if (fromEnv != null) {
588
+ return fromEnv;
589
+ }
590
+ const fromConfig = Number.parseInt(String(config?.gateway?.port ?? "").trim(), 10);
591
+ if (Number.isFinite(fromConfig) && fromConfig > 0) {
592
+ return fromConfig;
593
+ }
594
+ const fromCli = readGatewayPortViaOpenClawCli();
595
+ if (fromCli != null) {
596
+ return fromCli;
597
+ }
598
+ return null;
599
+ }
600
+
601
+ function resolveGatewayBaseUrl(options, config) {
602
+ const fromOption = normalizeBaseUrl(options.gateway);
603
+ if (fromOption) return fromOption;
604
+ const fromEnv =
605
+ normalizeBaseUrl(process.env.OPENCLAW_TESTNEXTIM_GATEWAY_BASE_URL) ||
606
+ normalizeBaseUrl(process.env.OPENCLAW_CUSTOM_IM_GATEWAY_BASE_URL);
607
+ if (fromEnv) return fromEnv;
608
+ const gatewayPort = resolveGatewayPort(config);
609
+ if (gatewayPort == null) {
610
+ fail(
611
+ [
612
+ "gateway base URL is not configured.",
613
+ "Set one of the following and retry:",
614
+ " - openclaw.json -> gateway.port",
615
+ " - OPENCLAW_GATEWAY_PORT env",
616
+ " - --gateway http://host:port",
617
+ ].join("\n"),
618
+ );
619
+ }
620
+ const bindMode = String(config?.gateway?.bind ?? "").trim().toLowerCase();
621
+ const customBindHost = String(config?.gateway?.customBindHost ?? "").trim();
622
+ const host =
623
+ String(process.env.OPENCLAW_GATEWAY_HOST ?? "").trim() ||
624
+ (bindMode === "custom" && customBindHost ? customBindHost : DEFAULT_GATEWAY_HOST);
625
+ return `http://${formatHostForUrl(host)}:${gatewayPort}`;
626
+ }
627
+
628
+ function isLoopbackBaseUrl(value) {
629
+ const normalized = normalizeBaseUrl(value);
630
+ if (!normalized) return false;
631
+ try {
632
+ const parsed = new URL(normalized);
633
+ const host = parsed.hostname.trim().toLowerCase();
634
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
635
+ } catch {
636
+ return false;
637
+ }
638
+ }
639
+
640
+ function resolveGatewayAuthToken(config) {
641
+ const fromConfig = String(config?.gateway?.auth?.token ?? "").trim();
642
+ if (fromConfig) return fromConfig;
643
+ return String(process.env.OPENCLAW_GATEWAY_TOKEN ?? "").trim();
644
+ }
645
+
646
+ function relayAuthHeaders() {
647
+ const token =
648
+ process.env.OPENIM_RELAY_TOKEN?.trim() ??
649
+ process.env.TESTNEXTIM_RELAY_TOKEN?.trim() ??
650
+ "";
651
+ if (!token) return {};
652
+ return { authorization: `Bearer ${token}` };
653
+ }
654
+
655
+ function relayGatewayHeaders(gatewayToken) {
656
+ const token = String(gatewayToken ?? "").trim();
657
+ if (!token) {
658
+ fail(`missing relay gateway token. run \`${CLI_COMMAND_NAME} pair <code>\` once to initialize bridge credentials.`);
659
+ }
660
+ return { authorization: `Bearer ${token}` };
661
+ }
662
+
663
+ function safeJsonParse(raw) {
664
+ try {
665
+ return JSON.parse(raw);
666
+ } catch {
667
+ return null;
668
+ }
669
+ }
670
+
671
+ function extractErrorMessage(decoded, fallback) {
672
+ if (decoded && typeof decoded === "object") {
673
+ if (typeof decoded.error === "string" && decoded.error.trim()) return decoded.error.trim();
674
+ if (typeof decoded.message === "string" && decoded.message.trim()) return decoded.message.trim();
675
+ }
676
+ return fallback;
677
+ }
678
+
679
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
680
+ const controller = new AbortController();
681
+ const timer = setTimeout(() => controller.abort(new Error(`fetch timeout after ${timeoutMs}ms`)), timeoutMs);
682
+ try {
683
+ return await fetch(url, {
684
+ ...options,
685
+ signal: controller.signal,
686
+ });
687
+ } finally {
688
+ clearTimeout(timer);
689
+ }
690
+ }
691
+
692
+ function normalizeGatewayIdRaw(value) {
693
+ return String(value ?? "")
694
+ .trim()
695
+ .toLowerCase()
696
+ .replace(/[^a-z0-9_-]/g, "-")
697
+ .replace(/-+/g, "-")
698
+ .replace(/^-+|-+$/g, "");
699
+ }
700
+
701
+ function generateRelayGatewayId(accountId) {
702
+ const normalizedAccount = normalizeGatewayIdRaw(accountId) || "default";
703
+ return `gw-${normalizedAccount}-${randomHex(4)}`;
704
+ }
705
+
706
+ function resolveRelayCredentialsFromSection(section) {
707
+ return {
708
+ gatewayId:
709
+ (typeof section?.relayGatewayId === "string" ? section.relayGatewayId.trim() : "") ||
710
+ process.env.TESTNEXTIM_RELAY_GATEWAY_ID?.trim() ||
711
+ "",
712
+ clientToken:
713
+ (typeof section?.relayClientToken === "string" ? section.relayClientToken.trim() : "") ||
714
+ process.env.TESTNEXTIM_RELAY_CLIENT_TOKEN?.trim() ||
715
+ "",
716
+ gatewayToken:
717
+ (typeof section?.relayGatewayToken === "string" ? section.relayGatewayToken.trim() : "") ||
718
+ process.env.TESTNEXTIM_RELAY_GATEWAY_TOKEN?.trim() ||
719
+ "",
720
+ };
721
+ }
722
+
723
+ function updateTestNextIMState(mutator) {
724
+ const statePath = resolveTestNextIMStatePath();
725
+ const current = ensureObject(readOptionalJsonFile(statePath));
726
+ const next = ensureObject(mutator(current) ?? current);
727
+ writeJsonFile(statePath, next);
728
+ return {
729
+ statePath,
730
+ state: next,
731
+ };
732
+ }
733
+
734
+ function persistTestNextIMSettings({
735
+ bindPublicBaseUrl,
736
+ transportMode,
737
+ }) {
738
+ return updateTestNextIMState((current) => {
739
+ const next = ensureObject(current);
740
+ if (bindPublicBaseUrl != null) {
741
+ const normalized = normalizeBaseUrl(bindPublicBaseUrl);
742
+ if (normalized) {
743
+ next.bindPublicBaseUrl = normalized;
744
+ } else {
745
+ delete next.bindPublicBaseUrl;
746
+ }
747
+ }
748
+ if (transportMode) {
749
+ next.chatTransportMode = normalizeChatTransportMode(transportMode, "relay");
750
+ }
751
+ return next;
752
+ });
753
+ }
754
+
755
+ function persistBindState({
756
+ accountId,
757
+ clientUserId,
758
+ accessToken,
759
+ }) {
760
+ return updateTestNextIMState((current) => {
761
+ const next = ensureObject(current);
762
+ const normalizedAccountId = normalizeAccountId(accountId);
763
+ const normalizedUserId = normalizeUserId(clientUserId);
764
+ const normalizedToken = String(accessToken ?? "").trim();
765
+ next.accountId = normalizedAccountId;
766
+ if (normalizedUserId) {
767
+ next.linkedUserId = normalizedUserId;
768
+ next.clientUserId = normalizedUserId;
769
+ }
770
+ if (normalizedToken) {
771
+ next.accessToken = normalizedToken;
772
+ }
773
+ return next;
774
+ });
775
+ }
776
+
777
+ function resetLocalBindingState({
778
+ accountId,
779
+ }) {
780
+ return updateTestNextIMState((current) => {
781
+ const next = ensureObject(current);
782
+ const normalizedAccountId = normalizeAccountId(accountId || next.accountId);
783
+ next.accountId = normalizedAccountId;
784
+ delete next.linkedUserId;
785
+ delete next.clientUserId;
786
+ delete next.accessToken;
787
+ next.relayGatewayId = generateRelayGatewayId(normalizedAccountId);
788
+ next.relayClientToken = randomHex(24);
789
+ next.relayGatewayToken = randomHex(24);
790
+ return next;
791
+ });
792
+ }
793
+
794
+ async function readRelayGatewayBinding({
795
+ relayUrl,
796
+ gatewayId,
797
+ gatewayToken,
798
+ }) {
799
+ const url = `${trimSlash(relayUrl)}/v1/gateways/${encodeURIComponent(gatewayId)}/binding`;
800
+ const response = await fetch(url, {
801
+ method: "GET",
802
+ headers: {
803
+ ...relayGatewayHeaders(gatewayToken),
804
+ },
805
+ });
806
+ const text = await response.text();
807
+ const decoded = text ? safeJsonParse(text) : null;
808
+ if (response.status === 404) {
809
+ return null;
810
+ }
811
+ if (!response.ok) {
812
+ const message = extractErrorMessage(
813
+ decoded,
814
+ `relay binding lookup failed (HTTP ${response.status})`,
815
+ );
816
+ fail(message);
817
+ }
818
+ const binding =
819
+ decoded && typeof decoded === "object" && decoded.binding && typeof decoded.binding === "object"
820
+ ? decoded.binding
821
+ : null;
822
+ if (!binding) {
823
+ return null;
824
+ }
825
+ return {
826
+ code: typeof binding.code === "string" ? binding.code.trim() : "",
827
+ clientUserId:
828
+ typeof binding.clientUserId === "string" ? binding.clientUserId.trim() : "",
829
+ accountId: typeof binding.accountId === "string" ? binding.accountId.trim() : "",
830
+ gatewayId: typeof binding.gatewayId === "string" ? binding.gatewayId.trim() : "",
831
+ expiresAt: typeof binding.expiresAt === "string" ? binding.expiresAt.trim() : "",
832
+ };
833
+ }
834
+
835
+ async function resetRelayGatewayBinding({
836
+ relayUrl,
837
+ gatewayId,
838
+ gatewayToken,
839
+ }) {
840
+ const url = `${trimSlash(relayUrl)}/v1/gateways/${encodeURIComponent(gatewayId)}/reset`;
841
+ const response = await fetch(url, {
842
+ method: "POST",
843
+ headers: {
844
+ ...relayGatewayHeaders(gatewayToken),
845
+ },
846
+ });
847
+ const text = await response.text();
848
+ const decoded = text ? safeJsonParse(text) : null;
849
+ if (response.status === 404) {
850
+ return {
851
+ ok: false,
852
+ notFound: true,
853
+ clearedCodes: [],
854
+ };
855
+ }
856
+ if (!response.ok) {
857
+ const message = extractErrorMessage(
858
+ decoded,
859
+ `relay reset failed (HTTP ${response.status})`,
860
+ );
861
+ fail(message);
862
+ }
863
+ return {
864
+ ok: true,
865
+ notFound: false,
866
+ clearedCodes: Array.isArray(decoded?.clearedCodes)
867
+ ? decoded.clearedCodes
868
+ .map((item) => String(item ?? "").trim())
869
+ .filter(Boolean)
870
+ : [],
871
+ };
872
+ }
873
+
874
+ function ensureRelayBridgeCredentials({
875
+ accountId,
876
+ relayUrl,
877
+ }) {
878
+ if (!relayUrl) {
879
+ return {
880
+ statePath: resolveTestNextIMStatePath(),
881
+ section: readChannelSection({}),
882
+ changed: false,
883
+ changes: [],
884
+ credentials: null,
885
+ };
886
+ }
887
+
888
+ const currentState = ensureObject(readOptionalJsonFile(resolveTestNextIMStatePath()));
889
+ const section = ensureObject(currentState);
890
+ const changes = [];
891
+
892
+ const current = resolveRelayCredentialsFromSection(section);
893
+ const gatewayId = current.gatewayId || generateRelayGatewayId(accountId);
894
+ const clientToken = current.clientToken || randomHex(24);
895
+ const gatewayToken = current.gatewayToken || randomHex(24);
896
+
897
+ if (!current.gatewayId) {
898
+ section.relayGatewayId = gatewayId;
899
+ changes.push("state.relayGatewayId=<generated>");
900
+ }
901
+ if (!current.clientToken) {
902
+ section.relayClientToken = clientToken;
903
+ changes.push("state.relayClientToken=<generated>");
904
+ }
905
+ if (!current.gatewayToken) {
906
+ section.relayGatewayToken = gatewayToken;
907
+ changes.push("state.relayGatewayToken=<generated>");
908
+ }
909
+
910
+ if (changes.length > 0) {
911
+ writeJsonFile(resolveTestNextIMStatePath(), section);
912
+ }
913
+
914
+ return {
915
+ statePath: resolveTestNextIMStatePath(),
916
+ section,
917
+ changed: changes.length > 0,
918
+ changes,
919
+ credentials: {
920
+ gatewayId,
921
+ clientToken,
922
+ gatewayToken,
923
+ },
924
+ };
925
+ }
926
+
927
+ function restartOpenClaw() {
928
+ const result = spawnSync("openclaw", ["daemon", "restart"], {
929
+ encoding: "utf8",
930
+ });
931
+ if (result.status === 0) {
932
+ return {
933
+ ok: true,
934
+ command: "openclaw daemon restart",
935
+ stdout: result.stdout ?? "",
936
+ stderr: result.stderr ?? "",
937
+ };
938
+ }
939
+ return {
940
+ ok: false,
941
+ command: "openclaw daemon restart",
942
+ stdout: result.stdout ?? "",
943
+ stderr: result.stderr ?? "",
944
+ status: result.status ?? 1,
945
+ };
946
+ }
947
+
948
+ function listBridgeProcesses({ relayUrl, gatewayBaseUrl }) {
949
+ let result;
950
+ if (process.platform === "win32") {
951
+ const script = [
952
+ "$ErrorActionPreference = 'SilentlyContinue'",
953
+ "Get-CimInstance Win32_Process |",
954
+ "Where-Object { $_.CommandLine -and $_.CommandLine -like '*testnextim*bridge*' } |",
955
+ "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }",
956
+ ].join(" ");
957
+ result = spawnSync("powershell.exe", ["-NoProfile", "-Command", script], {
958
+ encoding: "utf8",
959
+ windowsHide: true,
960
+ });
961
+ } else {
962
+ result = spawnSync("ps", ["-ax", "-o", "pid=,command="], {
963
+ encoding: "utf8",
964
+ });
965
+ }
966
+ if (result.status !== 0) {
967
+ return [];
968
+ }
969
+
970
+ return String(result.stdout ?? "")
971
+ .split(/\r?\n/)
972
+ .map((line) => line.trim())
973
+ .filter(Boolean)
974
+ .map((line) => {
975
+ const match = line.match(/^(\d+)\s+(.*)$/);
976
+ if (!match) return null;
977
+ return { pid: Number.parseInt(match[1], 10), command: match[2] };
978
+ })
979
+ .filter(Boolean)
980
+ .filter(
981
+ (entry) =>
982
+ Number.isFinite(entry.pid) &&
983
+ entry.pid !== process.pid &&
984
+ entry.command.includes("testnextim") &&
985
+ entry.command.includes("bridge") &&
986
+ entry.command.includes(relayUrl) &&
987
+ entry.command.includes(gatewayBaseUrl),
988
+ );
989
+ }
990
+
991
+ function stopBridgeProcesses({ relayUrl, gatewayBaseUrl }) {
992
+ const entries = listBridgeProcesses({ relayUrl, gatewayBaseUrl });
993
+ let stopped = 0;
994
+ for (const entry of entries) {
995
+ try {
996
+ process.kill(entry.pid, "SIGTERM");
997
+ stopped += 1;
998
+ } catch {}
999
+ }
1000
+ return {
1001
+ count: stopped,
1002
+ pids: entries.map((entry) => entry.pid),
1003
+ };
1004
+ }
1005
+
1006
+ function resolvePublishedGatewayBaseUrl({
1007
+ relayUrl,
1008
+ bindPublicBaseUrl,
1009
+ localGatewayBaseUrl,
1010
+ }) {
1011
+ const relayBase = normalizeBaseUrl(relayUrl);
1012
+ if (relayBase) return relayBase;
1013
+ const publicBindBase = normalizeBaseUrl(bindPublicBaseUrl);
1014
+ if (publicBindBase && isLoopbackBaseUrl(localGatewayBaseUrl)) {
1015
+ return publicBindBase;
1016
+ }
1017
+ return localGatewayBaseUrl;
1018
+ }
1019
+
1020
+ async function probeGatewayResponsesEndpoint({
1021
+ gatewayBaseUrl,
1022
+ gatewayAuthToken,
1023
+ }) {
1024
+ const url = buildGatewayResponsesUrl(gatewayBaseUrl);
1025
+ const headers = {
1026
+ "content-type": "application/json",
1027
+ "x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
1028
+ };
1029
+ if (gatewayAuthToken) {
1030
+ headers.authorization = `Bearer ${gatewayAuthToken}`;
1031
+ }
1032
+
1033
+ try {
1034
+ const response = await fetch(url, {
1035
+ method: "POST",
1036
+ headers,
1037
+ body: JSON.stringify({}),
1038
+ });
1039
+ return {
1040
+ ok: ![401, 403, 404].includes(response.status),
1041
+ status: response.status,
1042
+ url,
1043
+ };
1044
+ } catch (error) {
1045
+ return {
1046
+ ok: false,
1047
+ status: 0,
1048
+ url,
1049
+ error: String(error instanceof Error ? error.message : error),
1050
+ };
1051
+ }
1052
+ }
1053
+
1054
+ function prepareConfig({
1055
+ config,
1056
+ }) {
1057
+ let next = ensureObject(config);
1058
+ const changes = [];
1059
+
1060
+ const gateway = ensureObject(next.gateway);
1061
+ const auth = ensureObject(gateway.auth);
1062
+ const rawAuthMode = String(auth.mode ?? "").trim().toLowerCase();
1063
+ if (!rawAuthMode || rawAuthMode === "none") {
1064
+ auth.mode = "token";
1065
+ changes.push("gateway.auth.mode=token");
1066
+ }
1067
+ if (!String(auth.token ?? "").trim()) {
1068
+ auth.token = randomHex(24);
1069
+ changes.push("gateway.auth.token=<generated>");
1070
+ }
1071
+ gateway.auth = auth;
1072
+
1073
+ const http = ensureObject(gateway.http);
1074
+ const endpoints = ensureObject(http.endpoints);
1075
+ const responses = ensureObject(endpoints.responses);
1076
+ if (responses.enabled !== true) {
1077
+ responses.enabled = true;
1078
+ changes.push("gateway.http.endpoints.responses.enabled=true");
1079
+ }
1080
+ endpoints.responses = responses;
1081
+ http.endpoints = endpoints;
1082
+ gateway.http = http;
1083
+ next.gateway = gateway;
1084
+
1085
+ const channels = ensureObject(next.channels);
1086
+ if (channels.testnextim != null) {
1087
+ delete channels.testnextim;
1088
+ changes.push("channels.testnextim removed (independent CLI mode)");
1089
+ }
1090
+
1091
+ if (channels.openim != null) {
1092
+ delete channels.openim;
1093
+ changes.push("channels.openim removed (legacy cleanup)");
1094
+ }
1095
+
1096
+ if (channels["custom-im"] != null) {
1097
+ delete channels["custom-im"];
1098
+ changes.push("channels.custom-im removed (legacy cleanup)");
1099
+ }
1100
+ next.channels = channels;
1101
+
1102
+ const plugins = ensureObject(next.plugins);
1103
+ const entries = ensureObject(plugins.entries);
1104
+ if (entries.testnextim != null) {
1105
+ delete entries.testnextim;
1106
+ changes.push("plugins.entries.testnextim removed (independent CLI mode)");
1107
+ }
1108
+ plugins.entries = entries;
1109
+ const allow = ensureArray(plugins.allow);
1110
+ const filteredAllow = allow.filter((item) => item !== "testnextim");
1111
+ if (filteredAllow.length !== allow.length) {
1112
+ changes.push("plugins.allow -= testnextim");
1113
+ }
1114
+ plugins.allow = filteredAllow;
1115
+ next.plugins = plugins;
1116
+
1117
+ return {
1118
+ config: next,
1119
+ changed: changes.length > 0,
1120
+ changes,
1121
+ };
1122
+ }
1123
+
1124
+ async function publishPairToRelay({
1125
+ relayUrl,
1126
+ code,
1127
+ transportMode,
1128
+ clientUserId,
1129
+ bindUrl,
1130
+ gatewayBaseUrl,
1131
+ gatewayToken,
1132
+ relayGatewayId,
1133
+ relayClientToken,
1134
+ relayGatewayToken,
1135
+ accountId,
1136
+ }) {
1137
+ const url = `${relayUrl}/v1/pair-sessions`;
1138
+ const headers = {
1139
+ "content-type": "application/json",
1140
+ ...relayAuthHeaders(),
1141
+ };
1142
+ const response = await fetch(url, {
1143
+ method: "POST",
1144
+ headers,
1145
+ body: JSON.stringify({
1146
+ code,
1147
+ transportMode: normalizeChatTransportMode(transportMode, "relay"),
1148
+ ...(clientUserId ? { clientUserId } : {}),
1149
+ bindUrl,
1150
+ gatewayBaseUrl,
1151
+ ...(gatewayToken ? { gatewayToken } : {}),
1152
+ ...(relayGatewayId ? { relayGatewayId } : {}),
1153
+ ...(relayClientToken ? { relayClientToken } : {}),
1154
+ ...(relayGatewayToken ? { relayGatewayToken } : {}),
1155
+ accountId,
1156
+ ttlSeconds: 600,
1157
+ source: "testnextim-cli",
1158
+ }),
1159
+ });
1160
+ const text = await response.text();
1161
+ if (!response.ok) {
1162
+ fail(`relay publish failed (${response.status}): ${text || response.statusText}`);
1163
+ }
1164
+ return {
1165
+ relayLookupUrl: `${relayUrl}/v1/pair-sessions/${encodeURIComponent(code)}`,
1166
+ relayPayload: text ? safeJsonParse(text) : null,
1167
+ };
1168
+ }
1169
+
1170
+ async function readPairSessionFromRelay({ relayUrl, code }) {
1171
+ const url = `${relayUrl}/v1/pair-sessions/${encodeURIComponent(code)}`;
1172
+ const response = await fetch(url, { method: "GET" });
1173
+ const text = await response.text();
1174
+ const decoded = text ? safeJsonParse(text) : null;
1175
+ if (response.status === 404) {
1176
+ return null;
1177
+ }
1178
+ if (!response.ok) {
1179
+ const message = extractErrorMessage(
1180
+ decoded,
1181
+ `relay lookup failed (HTTP ${response.status})`,
1182
+ );
1183
+ fail(message);
1184
+ }
1185
+ const session =
1186
+ decoded && typeof decoded === "object" && decoded.session && typeof decoded.session === "object"
1187
+ ? decoded.session
1188
+ : null;
1189
+ if (!session) return null;
1190
+ return {
1191
+ code: typeof session.code === "string" ? session.code.trim() : "",
1192
+ transportMode: normalizeChatTransportMode(session.transportMode, "relay"),
1193
+ clientUserID:
1194
+ typeof session.clientUserId === "string"
1195
+ ? session.clientUserId.trim()
1196
+ : "",
1197
+ bindUrl: typeof session.bindUrl === "string" ? session.bindUrl.trim() : "",
1198
+ gatewayBaseUrl:
1199
+ typeof session.gatewayBaseUrl === "string"
1200
+ ? session.gatewayBaseUrl.trim()
1201
+ : "",
1202
+ accountId:
1203
+ typeof session.accountId === "string" ? session.accountId.trim() : "",
1204
+ source: typeof session.source === "string" ? session.source.trim() : "",
1205
+ consumed: session.consumed === true,
1206
+ };
1207
+ }
1208
+
1209
+ async function readRelayNextBindRequest({ relayUrl, code, after, waitSeconds }) {
1210
+ const url = new URL(`${relayUrl}/v1/pair-sessions/${encodeURIComponent(code)}/bind-requests/next`);
1211
+ if (after) {
1212
+ url.searchParams.set("after", after);
1213
+ }
1214
+ url.searchParams.set("waitSeconds", String(waitSeconds));
1215
+
1216
+ const response = await fetch(url, {
1217
+ method: "GET",
1218
+ headers: {
1219
+ ...relayAuthHeaders(),
1220
+ },
1221
+ });
1222
+
1223
+ if (response.status === 204) {
1224
+ return null;
1225
+ }
1226
+ const text = await response.text();
1227
+ const decoded = text ? safeJsonParse(text) : null;
1228
+ if (!response.ok) {
1229
+ const message = extractErrorMessage(
1230
+ decoded,
1231
+ `relay bind request poll failed (HTTP ${response.status})`,
1232
+ );
1233
+ fail(message);
1234
+ }
1235
+
1236
+ const request = decoded?.request;
1237
+ if (!request || typeof request !== "object") {
1238
+ return null;
1239
+ }
1240
+ return {
1241
+ requestId: typeof request.requestId === "string" ? request.requestId.trim() : "",
1242
+ userId: typeof request.userId === "string" ? request.userId.trim() : "",
1243
+ accessToken:
1244
+ typeof request.accessToken === "string" ? request.accessToken.trim() : "",
1245
+ accountId:
1246
+ typeof request.accountId === "string" ? request.accountId.trim() : undefined,
1247
+ restart:
1248
+ typeof request.restart === "boolean"
1249
+ ? request.restart
1250
+ : request.restart == null
1251
+ ? undefined
1252
+ : Boolean(request.restart),
1253
+ requestedAt:
1254
+ typeof request.requestedAt === "string" ? request.requestedAt.trim() : undefined,
1255
+ };
1256
+ }
1257
+
1258
+ async function publishRelayBindResult({ relayUrl, code, payload }) {
1259
+ const url = `${relayUrl}/v1/pair-sessions/${encodeURIComponent(code)}/bind-results`;
1260
+ const response = await fetch(url, {
1261
+ method: "POST",
1262
+ headers: {
1263
+ "content-type": "application/json",
1264
+ ...relayAuthHeaders(),
1265
+ },
1266
+ body: JSON.stringify(payload),
1267
+ });
1268
+
1269
+ const text = await response.text();
1270
+ if (!response.ok) {
1271
+ const decoded = text ? safeJsonParse(text) : null;
1272
+ const message = extractErrorMessage(
1273
+ decoded,
1274
+ `relay bind result publish failed (HTTP ${response.status})`,
1275
+ );
1276
+ fail(message);
1277
+ }
1278
+ }
1279
+
1280
+ async function waitAndProxyBind({
1281
+ configPath,
1282
+ relayUrl,
1283
+ code,
1284
+ accountId,
1285
+ waitSeconds,
1286
+ json,
1287
+ }) {
1288
+ const deadline = Date.now() + waitSeconds * 1000;
1289
+ let after = new Date(Date.now() - 1000).toISOString();
1290
+
1291
+ if (!json) {
1292
+ console.log(`Relay worker waiting for bind request (${waitSeconds}s)...`);
1293
+ }
1294
+
1295
+ while (Date.now() < deadline) {
1296
+ const remainingSeconds = Math.max(1, Math.ceil((deadline - Date.now()) / 1000));
1297
+ const request = await readRelayNextBindRequest({
1298
+ relayUrl,
1299
+ code,
1300
+ after,
1301
+ waitSeconds: Math.min(remainingSeconds, 30),
1302
+ });
1303
+
1304
+ if (!request) {
1305
+ continue;
1306
+ }
1307
+
1308
+ if (request.requestedAt) {
1309
+ after = request.requestedAt;
1310
+ } else {
1311
+ after = new Date().toISOString();
1312
+ }
1313
+
1314
+ if (!request.requestId) {
1315
+ continue;
1316
+ }
1317
+
1318
+ const bindAccountId = normalizeAccountId(request.accountId || accountId);
1319
+ const linkedUserId = request.userId || request.accessToken || "";
1320
+
1321
+ if (!json) {
1322
+ console.log(`Relay bind request received: ${request.requestId}`);
1323
+ console.log(`Recording relay bind state for account: ${bindAccountId}`);
1324
+ }
1325
+
1326
+ let bindError = null;
1327
+ let restartState = { ok: true, restarted: false };
1328
+ try {
1329
+ persistBindState({
1330
+ accountId: bindAccountId,
1331
+ clientUserId: linkedUserId,
1332
+ accessToken: request.accessToken,
1333
+ });
1334
+ const shouldRestart =
1335
+ typeof request.restart === "boolean" ? request.restart : true;
1336
+ if (shouldRestart) {
1337
+ const restartResult = restartOpenClaw();
1338
+ restartState = { ok: restartResult.ok, restarted: true };
1339
+ if (!restartResult.ok) {
1340
+ bindError =
1341
+ restartResult.stderr?.trim() ||
1342
+ restartResult.stdout?.trim() ||
1343
+ "openclaw restart failed";
1344
+ }
1345
+ }
1346
+ } catch (error) {
1347
+ bindError = String(error instanceof Error ? error.message : error);
1348
+ }
1349
+
1350
+ const payload = bindError
1351
+ ? {
1352
+ requestId: request.requestId,
1353
+ status: "error",
1354
+ error: bindError,
1355
+ message: "failed to apply relay bind state on gateway host",
1356
+ configUpdated: false,
1357
+ restarted: restartState.restarted,
1358
+ mode: "relay",
1359
+ }
1360
+ : {
1361
+ requestId: request.requestId,
1362
+ status: "ok",
1363
+ message: "relay bind state saved",
1364
+ configUpdated: true,
1365
+ restarted: restartState.restarted,
1366
+ mode: "relay",
1367
+ };
1368
+
1369
+ await publishRelayBindResult({ relayUrl, code, payload });
1370
+
1371
+ if (payload.status !== "ok") {
1372
+ fail(`gateway bind failed: ${bindError}`);
1373
+ }
1374
+
1375
+ return {
1376
+ requestId: request.requestId,
1377
+ gatewayStatus: 200,
1378
+ message: payload.message ?? "bind success",
1379
+ configUpdated: payload.configUpdated === true,
1380
+ restarted: payload.restarted === true,
1381
+ mode: payload.mode ?? null,
1382
+ };
1383
+ }
1384
+
1385
+ fail("relay bind worker timeout: no bind request received");
1386
+ }
1387
+
1388
+ async function readRelayNextGatewayRequest({
1389
+ relayUrl,
1390
+ gatewayId,
1391
+ gatewayToken,
1392
+ waitSeconds,
1393
+ }) {
1394
+ const url = new URL(`${relayUrl}/v1/gateways/${encodeURIComponent(gatewayId)}/requests/next`);
1395
+ url.searchParams.set("waitSeconds", String(waitSeconds));
1396
+
1397
+ let response;
1398
+ try {
1399
+ response = await fetchWithTimeout(url, {
1400
+ method: "GET",
1401
+ headers: {
1402
+ ...relayGatewayHeaders(gatewayToken),
1403
+ },
1404
+ }, (Math.max(waitSeconds, 1) + 15) * 1000);
1405
+ } catch (error) {
1406
+ const message = String(error instanceof Error ? error.message : error);
1407
+ if (message.includes("timeout")) {
1408
+ return null;
1409
+ }
1410
+ throw error;
1411
+ }
1412
+
1413
+ if (response.status === 204) {
1414
+ return null;
1415
+ }
1416
+
1417
+ const text = await response.text();
1418
+ const decoded = text ? safeJsonParse(text) : null;
1419
+ if (!response.ok) {
1420
+ const message = extractErrorMessage(
1421
+ decoded,
1422
+ `relay gateway poll failed (HTTP ${response.status})`,
1423
+ );
1424
+ fail(message);
1425
+ }
1426
+
1427
+ const request = decoded?.request;
1428
+ if (!request || typeof request !== "object") return null;
1429
+
1430
+ return {
1431
+ requestId: typeof request.requestId === "string" ? request.requestId.trim() : "",
1432
+ agentId: typeof request.agentId === "string" ? request.agentId.trim() : "main",
1433
+ sessionKey:
1434
+ typeof request.sessionKey === "string" ? request.sessionKey.trim() : "",
1435
+ stream: request.stream === true,
1436
+ request: request.request ?? null,
1437
+ };
1438
+ }
1439
+
1440
+ async function publishRelayGatewayEvent({
1441
+ relayUrl,
1442
+ gatewayId,
1443
+ gatewayToken,
1444
+ requestId,
1445
+ payload,
1446
+ }) {
1447
+ const url = `${relayUrl}/v1/gateways/${encodeURIComponent(gatewayId)}/requests/${encodeURIComponent(requestId)}/events`;
1448
+ const response = await fetchWithTimeout(url, {
1449
+ method: "POST",
1450
+ headers: {
1451
+ "content-type": "application/json",
1452
+ ...relayGatewayHeaders(gatewayToken),
1453
+ },
1454
+ body: JSON.stringify(payload),
1455
+ }, 20000);
1456
+
1457
+ const text = await response.text();
1458
+ if (!response.ok) {
1459
+ const decoded = text ? safeJsonParse(text) : null;
1460
+ const message = extractErrorMessage(
1461
+ decoded,
1462
+ `relay gateway event publish failed (HTTP ${response.status})`,
1463
+ );
1464
+ throw new Error(message);
1465
+ }
1466
+ }
1467
+
1468
+ async function publishRelayGatewayChatEvent({
1469
+ relayUrl,
1470
+ gatewayId,
1471
+ gatewayToken,
1472
+ payload,
1473
+ }) {
1474
+ const url = `${relayUrl}/v1/gateways/${encodeURIComponent(gatewayId)}/chat-events`;
1475
+ const response = await fetchWithTimeout(url, {
1476
+ method: "POST",
1477
+ headers: {
1478
+ "content-type": "application/json",
1479
+ ...relayGatewayHeaders(gatewayToken),
1480
+ },
1481
+ body: JSON.stringify(payload ?? {}),
1482
+ }, 20000);
1483
+
1484
+ const text = await response.text();
1485
+ if (!response.ok) {
1486
+ const decoded = text ? safeJsonParse(text) : null;
1487
+ const message = extractErrorMessage(
1488
+ decoded,
1489
+ `relay gateway chat event publish failed (HTTP ${response.status})`,
1490
+ );
1491
+ throw new Error(message);
1492
+ }
1493
+ }
1494
+
1495
+ function parseRelayRpcRequest(gatewayRequest) {
1496
+ const payload =
1497
+ gatewayRequest?.request && typeof gatewayRequest.request === "object"
1498
+ ? gatewayRequest.request
1499
+ : null;
1500
+ if (!payload || payload.__relayKind !== "rpc") {
1501
+ return null;
1502
+ }
1503
+ const method = typeof payload.method === "string" ? payload.method.trim() : "";
1504
+ if (!method) {
1505
+ return null;
1506
+ }
1507
+ const params =
1508
+ payload.params && typeof payload.params === "object" && !Array.isArray(payload.params)
1509
+ ? payload.params
1510
+ : {};
1511
+ return { method, params };
1512
+ }
1513
+
1514
+ function parseJsonFromOutput(raw) {
1515
+ const text = String(raw ?? "").trim();
1516
+ if (!text) return null;
1517
+ const parsed = safeJsonParse(text);
1518
+ if (parsed != null) return parsed;
1519
+ const lines = text
1520
+ .split(/\r?\n/)
1521
+ .map((line) => line.trim())
1522
+ .filter((line) => line.length > 0);
1523
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1524
+ const decoded = safeJsonParse(lines[i]);
1525
+ if (decoded != null) {
1526
+ return decoded;
1527
+ }
1528
+ }
1529
+ return null;
1530
+ }
1531
+
1532
+ // ---------------------------------------------------------------------------
1533
+ // Gateway device identity + signed connect payload (ported from clawpilot)
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ const GATEWAY_PROTOCOL_VERSION = 3;
1537
+ const GATEWAY_CLIENT_IDENTITY_PATH = path.join(
1538
+ os.homedir(),
1539
+ ".clawai",
1540
+ "device-identity.json",
1541
+ );
1542
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
1543
+
1544
+ function base64UrlEncode(buf) {
1545
+ return Buffer.from(buf)
1546
+ .toString("base64")
1547
+ .replaceAll("+", "-")
1548
+ .replaceAll("/", "_")
1549
+ .replace(/=+$/g, "");
1550
+ }
1551
+
1552
+ function rawPublicKeyBytes(publicKeyPem) {
1553
+ const key = createPublicKey(publicKeyPem);
1554
+ const spki = key.export({ type: "spki", format: "der" });
1555
+ if (
1556
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
1557
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
1558
+ ) {
1559
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
1560
+ }
1561
+ return spki;
1562
+ }
1563
+
1564
+ function loadOrCreateGatewayDeviceIdentity() {
1565
+ if (fs.existsSync(GATEWAY_CLIENT_IDENTITY_PATH)) {
1566
+ try {
1567
+ const stored = JSON.parse(fs.readFileSync(GATEWAY_CLIENT_IDENTITY_PATH, "utf8"));
1568
+ if (stored?.deviceId && stored?.publicKeyPem && stored?.privateKeyPem) {
1569
+ return {
1570
+ deviceId: String(stored.deviceId),
1571
+ publicKeyPem: String(stored.publicKeyPem),
1572
+ privateKeyPem: String(stored.privateKeyPem),
1573
+ };
1574
+ }
1575
+ } catch {
1576
+ // fall through and regenerate
1577
+ }
1578
+ }
1579
+
1580
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
1581
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
1582
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
1583
+ const deviceId = createHash("sha256")
1584
+ .update(rawPublicKeyBytes(publicKeyPem))
1585
+ .digest("hex");
1586
+ const identity = { deviceId, publicKeyPem, privateKeyPem };
1587
+ fs.mkdirSync(path.dirname(GATEWAY_CLIENT_IDENTITY_PATH), { recursive: true });
1588
+ fs.writeFileSync(
1589
+ GATEWAY_CLIENT_IDENTITY_PATH,
1590
+ `${JSON.stringify({ version: 1, ...identity, createdAtMs: Date.now() }, null, 2)}\n`,
1591
+ { mode: 0o600 },
1592
+ );
1593
+ return identity;
1594
+ }
1595
+
1596
+ function buildSignedGatewayDevice(identity, opts) {
1597
+ const version = opts.nonce ? "v2" : "v1";
1598
+ const payload = [
1599
+ version,
1600
+ identity.deviceId,
1601
+ opts.clientId,
1602
+ opts.clientMode,
1603
+ opts.role,
1604
+ opts.scopes.join(","),
1605
+ String(opts.signedAtMs),
1606
+ opts.token ?? "",
1607
+ ...(version === "v2" ? [opts.nonce ?? ""] : []),
1608
+ ].join("|");
1609
+ const key = createPrivateKey(identity.privateKeyPem);
1610
+ const signature = base64UrlEncode(sign(null, Buffer.from(payload, "utf8"), key));
1611
+ return {
1612
+ id: identity.deviceId,
1613
+ publicKey: base64UrlEncode(rawPublicKeyBytes(identity.publicKeyPem)),
1614
+ signature,
1615
+ signedAt: opts.signedAtMs,
1616
+ nonce: opts.nonce,
1617
+ };
1618
+ }
1619
+
1620
+ // ---------------------------------------------------------------------------
1621
+ // Persistent WebSocket gateway client (like clawpilot's OpenClawGatewayClient)
1622
+ // ---------------------------------------------------------------------------
1623
+
1624
+ let _gatewayWsClient = null;
1625
+ let _gatewayWsBridgePublisher = null;
1626
+
1627
+ function setGatewayWsBridgePublisher(publisher) {
1628
+ _gatewayWsBridgePublisher = typeof publisher === "function" ? publisher : null;
1629
+ }
1630
+
1631
+ function getOrCreateGatewayWsClient({ gatewayBaseUrl, gatewayAuthToken }) {
1632
+ if (_gatewayWsClient && _gatewayWsClient.wsUrl === buildGatewayWsUrl(gatewayBaseUrl)) {
1633
+ return _gatewayWsClient;
1634
+ }
1635
+ if (_gatewayWsClient) {
1636
+ _gatewayWsClient.close();
1637
+ }
1638
+ _gatewayWsClient = createGatewayWsClient({ gatewayBaseUrl, gatewayAuthToken });
1639
+ return _gatewayWsClient;
1640
+ }
1641
+
1642
+ function createGatewayWsClient({ gatewayBaseUrl, gatewayAuthToken }) {
1643
+ const wsUrl = buildGatewayWsUrl(gatewayBaseUrl);
1644
+ const pending = new Map();
1645
+ let ws = null;
1646
+ let identity = loadOrCreateGatewayDeviceIdentity();
1647
+ let connected = false;
1648
+ let connectPromise = null;
1649
+ let stopped = false;
1650
+ let connectTimer = null;
1651
+ let tickTimer = null;
1652
+ let connectSent = false;
1653
+ let connectNonce = null;
1654
+ let storedDeviceToken = null;
1655
+ let tickIntervalMs = 30000;
1656
+ let lastTickAt = 0;
1657
+
1658
+ function doConnect() {
1659
+ if (stopped) return Promise.reject(new Error("gateway client stopped"));
1660
+ if (connected && ws?.readyState === 1) return Promise.resolve();
1661
+ if (connectPromise) return connectPromise;
1662
+
1663
+ connectPromise = new Promise(async (resolve, reject) => {
1664
+ // Node 22+ has global WebSocket; older Node needs the ws package
1665
+ let WsClass = typeof WebSocket !== "undefined" ? WebSocket : null;
1666
+ if (!WsClass) {
1667
+ try {
1668
+ const mod = await import("ws");
1669
+ WsClass = mod.default || mod.WebSocket;
1670
+ } catch {
1671
+ reject(new Error("WebSocket not available (install ws package or use Node 22+)"));
1672
+ connectPromise = null;
1673
+ return;
1674
+ }
1675
+ }
1676
+
1677
+ try {
1678
+ ws = new WsClass(wsUrl);
1679
+ } catch (err) {
1680
+ reject(new Error(`gateway ws create failed: ${err}`));
1681
+ connectPromise = null;
1682
+ return;
1683
+ }
1684
+
1685
+ const timeout = setTimeout(() => {
1686
+ if (!connected) {
1687
+ ws?.close();
1688
+ reject(new Error("gateway websocket connect timeout"));
1689
+ connectPromise = null;
1690
+ }
1691
+ }, 15000);
1692
+
1693
+ function cleanup() {
1694
+ clearTimeout(timeout);
1695
+ if (connectTimer) {
1696
+ clearTimeout(connectTimer);
1697
+ connectTimer = null;
1698
+ }
1699
+ if (tickTimer) {
1700
+ clearInterval(tickTimer);
1701
+ tickTimer = null;
1702
+ }
1703
+ connected = false;
1704
+ connectSent = false;
1705
+ connectNonce = null;
1706
+ lastTickAt = 0;
1707
+ connectPromise = null;
1708
+ flushPending(new Error("gateway disconnected"));
1709
+ }
1710
+
1711
+ ws.onopen = () => {
1712
+ connectSent = false;
1713
+ connectNonce = null;
1714
+ connectTimer = setTimeout(() => sendConnect(), 1000);
1715
+ };
1716
+
1717
+ ws.onmessage = (event) => {
1718
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
1719
+ handleMessage(raw);
1720
+ };
1721
+
1722
+ ws.onclose = () => cleanup();
1723
+
1724
+ ws.onerror = (err) => {
1725
+ if (!connected) {
1726
+ clearTimeout(timeout);
1727
+ reject(new Error(`gateway ws error: ${err?.message || err}`));
1728
+ connectPromise = null;
1729
+ }
1730
+ };
1731
+
1732
+ function handleMessage(raw) {
1733
+ let parsed;
1734
+ try { parsed = JSON.parse(raw); } catch { return; }
1735
+ if (typeof parsed?.type !== "string") return;
1736
+
1737
+ if (parsed.type === "event") {
1738
+ const eventName = typeof parsed.event === "string" ? parsed.event.trim() : "";
1739
+ if (eventName === "connect.challenge") {
1740
+ const nonce = typeof parsed.payload?.nonce === "string"
1741
+ ? parsed.payload.nonce
1742
+ : null;
1743
+ if (nonce) {
1744
+ connectNonce = nonce;
1745
+ }
1746
+ sendConnect();
1747
+ return;
1748
+ }
1749
+ if (eventName === "tick") {
1750
+ lastTickAt = Date.now();
1751
+ return;
1752
+ }
1753
+ return;
1754
+ }
1755
+
1756
+ if (parsed.type === "res") {
1757
+ const p = pending.get(parsed.id);
1758
+ if (!p) return;
1759
+ pending.delete(parsed.id);
1760
+ if (parsed.ok) {
1761
+ p.resolve(parsed.payload);
1762
+ } else {
1763
+ p.reject(new Error(parsed.error?.message ?? "gateway rpc error"));
1764
+ }
1765
+ return;
1766
+ }
1767
+
1768
+ if (parsed.type === "req") {
1769
+ const requestId = typeof parsed.id === "string" ? parsed.id : "";
1770
+ const method = typeof parsed.method === "string" ? parsed.method.trim() : "";
1771
+ const params =
1772
+ parsed.params && typeof parsed.params === "object" ? parsed.params : null;
1773
+
1774
+ if (requestId && ws?.readyState === 1) {
1775
+ ws.send(JSON.stringify({ type: "res", id: requestId, ok: true }));
1776
+ }
1777
+
1778
+ if (method === "chat.push" && params && _gatewayWsBridgePublisher) {
1779
+ Promise.resolve()
1780
+ .then(() => _gatewayWsBridgePublisher("chat", params, { gatewayBaseUrl, gatewayAuthToken }))
1781
+ .catch((error) => {
1782
+ console.error(`[testnextim] relay chat event publish failed: ${String(error?.message ?? error)}`);
1783
+ });
1784
+ }
1785
+ }
1786
+ }
1787
+
1788
+ function startTickWatch() {
1789
+ if (tickTimer) {
1790
+ clearInterval(tickTimer);
1791
+ }
1792
+ const interval = Math.max(tickIntervalMs, 1000);
1793
+ tickTimer = setInterval(() => {
1794
+ if (stopped || !lastTickAt) {
1795
+ return;
1796
+ }
1797
+ if (Date.now() - lastTickAt > tickIntervalMs * 2) {
1798
+ ws?.close(4000, "tick timeout");
1799
+ }
1800
+ }, interval);
1801
+ }
1802
+
1803
+ function sendConnect() {
1804
+ if (connectSent) return;
1805
+ connectSent = true;
1806
+ if (connectTimer) {
1807
+ clearTimeout(connectTimer);
1808
+ connectTimer = null;
1809
+ }
1810
+ const role = "operator";
1811
+ const scopes = [
1812
+ "operator.admin",
1813
+ "operator.read",
1814
+ "operator.write",
1815
+ "operator.approvals",
1816
+ "operator.pairing",
1817
+ ];
1818
+ const clientId = "openclaw-macos";
1819
+ const clientMode = "ui";
1820
+ const signedAtMs = Date.now();
1821
+ const authToken = storedDeviceToken ?? gatewayAuthToken ?? undefined;
1822
+ const device = buildSignedGatewayDevice(identity, {
1823
+ clientId,
1824
+ clientMode,
1825
+ role,
1826
+ scopes,
1827
+ signedAtMs,
1828
+ token: authToken,
1829
+ nonce: connectNonce ?? undefined,
1830
+ });
1831
+ const params = {
1832
+ minProtocol: GATEWAY_PROTOCOL_VERSION,
1833
+ maxProtocol: GATEWAY_PROTOCOL_VERSION,
1834
+ role,
1835
+ scopes,
1836
+ caps: ["tool-events"],
1837
+ client: {
1838
+ id: clientId,
1839
+ displayName: "TestNextIM Bridge",
1840
+ version: "1.0.0",
1841
+ platform: process.platform,
1842
+ mode: clientMode,
1843
+ },
1844
+ device,
1845
+ auth: authToken ? { token: authToken } : undefined,
1846
+ };
1847
+ request("connect", params).then((hello) => {
1848
+ clearTimeout(timeout);
1849
+ connected = true;
1850
+ const deviceToken = hello?.auth?.deviceToken;
1851
+ if (typeof deviceToken === "string" && deviceToken.trim()) {
1852
+ storedDeviceToken = deviceToken.trim();
1853
+ }
1854
+ if (typeof hello?.policy?.tickIntervalMs === "number" && hello.policy.tickIntervalMs > 0) {
1855
+ tickIntervalMs = hello.policy.tickIntervalMs;
1856
+ }
1857
+ lastTickAt = Date.now();
1858
+ startTickWatch();
1859
+ resolve();
1860
+ }).catch((err) => {
1861
+ storedDeviceToken = null;
1862
+ clearTimeout(timeout);
1863
+ ws?.close();
1864
+ reject(err);
1865
+ });
1866
+ }
1867
+ });
1868
+
1869
+ return connectPromise;
1870
+ }
1871
+
1872
+ function request(method, params) {
1873
+ if (!ws || ws.readyState !== 1) {
1874
+ return Promise.reject(new Error("gateway not connected"));
1875
+ }
1876
+ const id = randomUUID();
1877
+ const frame = { type: "req", id, method, params };
1878
+ return new Promise((resolve, reject) => {
1879
+ const timer = setTimeout(() => {
1880
+ pending.delete(id);
1881
+ reject(new Error(`gateway rpc timeout: ${method}`));
1882
+ }, 30000);
1883
+ pending.set(id, {
1884
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
1885
+ reject: (e) => { clearTimeout(timer); reject(e); },
1886
+ });
1887
+ ws.send(JSON.stringify(frame));
1888
+ });
1889
+ }
1890
+
1891
+ function flushPending(err) {
1892
+ for (const p of pending.values()) p.reject(err);
1893
+ pending.clear();
1894
+ }
1895
+
1896
+ return {
1897
+ wsUrl,
1898
+ async call(method, params) {
1899
+ await doConnect();
1900
+ return request(method, params);
1901
+ },
1902
+ close() {
1903
+ stopped = true;
1904
+ ws?.close();
1905
+ ws = null;
1906
+ flushPending(new Error("gateway client closed"));
1907
+ },
1908
+ };
1909
+ }
1910
+
1911
+ async function callGatewayRpcLocal({
1912
+ gatewayBaseUrl,
1913
+ gatewayAuthToken,
1914
+ method,
1915
+ params,
1916
+ }) {
1917
+ if (method === "__gateway.features") {
1918
+ return {
1919
+ ok: true,
1920
+ methods: ["sessions.list", "sessions.abort", "sessions.usage", "sessions.patch",
1921
+ "usage.cost", "agents.files.list", "agents.files.get", "agents.files.set",
1922
+ "skills.status", "models.list", "chat.history", "chat.abort",
1923
+ "config.get", "config.patch"],
1924
+ };
1925
+ }
1926
+
1927
+ // In bridge mode the CLI path is more reliable than the shared WebSocket client,
1928
+ // especially while long-running streamed chats are also active.
1929
+ return callGatewayRpcLocalViaCli({ gatewayBaseUrl, gatewayAuthToken, method, params });
1930
+ }
1931
+
1932
+ async function callGatewayRpcLocalViaCli({
1933
+ gatewayBaseUrl,
1934
+ gatewayAuthToken,
1935
+ method,
1936
+ params,
1937
+ }) {
1938
+ const wsUrl = buildGatewayWsUrl(gatewayBaseUrl);
1939
+ const args = [
1940
+ "gateway",
1941
+ "call",
1942
+ method,
1943
+ "--url",
1944
+ wsUrl,
1945
+ "--params",
1946
+ JSON.stringify(params ?? {}),
1947
+ "--json",
1948
+ ];
1949
+ if (gatewayAuthToken) {
1950
+ args.push("--token", gatewayAuthToken);
1951
+ }
1952
+
1953
+ const child = spawn("openclaw", args, {
1954
+ stdio: ["ignore", "pipe", "pipe"],
1955
+ });
1956
+ let timedOut = false;
1957
+ const timeout = setTimeout(() => {
1958
+ timedOut = true;
1959
+ try {
1960
+ child.kill("SIGKILL");
1961
+ } catch {
1962
+ // noop
1963
+ }
1964
+ }, GATEWAY_RPC_CLI_TIMEOUT_MS);
1965
+
1966
+ const [stdoutChunks, stderrChunks] = await Promise.all([
1967
+ new Promise((resolve) => {
1968
+ const chunks = [];
1969
+ child.stdout?.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
1970
+ child.stdout?.on("end", () => resolve(chunks));
1971
+ child.stdout?.on("error", () => resolve(chunks));
1972
+ }),
1973
+ new Promise((resolve) => {
1974
+ const chunks = [];
1975
+ child.stderr?.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
1976
+ child.stderr?.on("end", () => resolve(chunks));
1977
+ child.stderr?.on("error", () => resolve(chunks));
1978
+ }),
1979
+ new Promise((resolve) => child.on("close", resolve)),
1980
+ ]);
1981
+ clearTimeout(timeout);
1982
+
1983
+ const stdoutText = Buffer.concat(stdoutChunks).toString("utf8");
1984
+ const stderrText = Buffer.concat(stderrChunks).toString("utf8");
1985
+ if (timedOut) {
1986
+ throw new Error(`openclaw gateway call timed out after ${Math.round(GATEWAY_RPC_CLI_TIMEOUT_MS / 1000)}s`);
1987
+ }
1988
+ const exitCode = child.exitCode ?? 1;
1989
+ if (exitCode !== 0) {
1990
+ const msg = stderrText.trim() || stdoutText.trim() || `openclaw gateway call failed (${exitCode})`;
1991
+ throw new Error(msg);
1992
+ }
1993
+
1994
+ const decoded = parseJsonFromOutput(stdoutText);
1995
+ if (decoded == null) {
1996
+ if (!stdoutText.trim()) {
1997
+ return { ok: true, payload: null };
1998
+ }
1999
+ throw new Error("gateway rpc returned non-JSON output");
2000
+ }
2001
+ return decoded;
2002
+ }
2003
+
2004
+ function extractAssistantTextFromHistory(history) {
2005
+ const messages = Array.isArray(history?.messages) ? history.messages : [];
2006
+ const assistant = [...messages]
2007
+ .reverse()
2008
+ .find((item) => String(item?.role ?? "").trim().toLowerCase() === "assistant");
2009
+ if (!assistant || !Array.isArray(assistant.content)) {
2010
+ return "";
2011
+ }
2012
+ for (const block of assistant.content) {
2013
+ if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
2014
+ return block.text.trim();
2015
+ }
2016
+ }
2017
+ return "";
2018
+ }
2019
+
2020
+ async function handleGatewayChatEvent({
2021
+ relayUrl,
2022
+ gatewayId,
2023
+ gatewayToken,
2024
+ gatewayBaseUrl,
2025
+ gatewayAuthToken,
2026
+ payload,
2027
+ }) {
2028
+ const eventPayload =
2029
+ payload && typeof payload === "object" && !Array.isArray(payload) ? { ...payload } : {};
2030
+ const sessionKey = typeof eventPayload.sessionKey === "string"
2031
+ ? eventPayload.sessionKey.trim()
2032
+ : "";
2033
+ const state = typeof eventPayload.state === "string" ? eventPayload.state.trim() : "";
2034
+ let text = "";
2035
+
2036
+ if (state === "final" && sessionKey) {
2037
+ try {
2038
+ const client = getOrCreateGatewayWsClient({ gatewayBaseUrl, gatewayAuthToken });
2039
+ let history = await client.call("chat.history", { sessionKey, limit: 10 });
2040
+ text = extractAssistantTextFromHistory(history);
2041
+ if (!text) {
2042
+ await new Promise((resolve) => setTimeout(resolve, 600));
2043
+ history = await client.call("chat.history", { sessionKey, limit: 10 });
2044
+ text = extractAssistantTextFromHistory(history);
2045
+ }
2046
+ } catch (error) {
2047
+ console.warn(`[testnextim] chat.history fetch failed for relay event: ${String(error?.message ?? error)}`);
2048
+ }
2049
+ }
2050
+
2051
+ await publishRelayGatewayChatEvent({
2052
+ relayUrl,
2053
+ gatewayId,
2054
+ gatewayToken,
2055
+ payload: {
2056
+ event: "chat",
2057
+ state,
2058
+ sessionKey,
2059
+ runId: typeof eventPayload.runId === "string" ? eventPayload.runId.trim() : "",
2060
+ text,
2061
+ payload: eventPayload,
2062
+ },
2063
+ });
2064
+ }
2065
+
2066
+ async function proxyGatewayRequestLocal({
2067
+ relayUrl,
2068
+ gatewayId,
2069
+ gatewayToken,
2070
+ gatewayBaseUrl,
2071
+ gatewayAuthToken,
2072
+ gatewayRequest,
2073
+ json,
2074
+ }) {
2075
+ const requestId = gatewayRequest.requestId;
2076
+ if (!requestId) {
2077
+ return;
2078
+ }
2079
+
2080
+ const relayRpc = parseRelayRpcRequest(gatewayRequest);
2081
+ if (relayRpc) {
2082
+ try {
2083
+ const rpcResult = await callGatewayRpcLocal({
2084
+ gatewayBaseUrl,
2085
+ gatewayAuthToken,
2086
+ method: relayRpc.method,
2087
+ params: relayRpc.params,
2088
+ });
2089
+ await publishRelayGatewayEvent({
2090
+ relayUrl,
2091
+ gatewayId,
2092
+ gatewayToken,
2093
+ requestId,
2094
+ payload: {
2095
+ event: "response",
2096
+ statusCode: 200,
2097
+ contentType: "application/json; charset=utf-8",
2098
+ body: JSON.stringify(rpcResult ?? {}),
2099
+ },
2100
+ });
2101
+ } catch (error) {
2102
+ await publishRelayGatewayEvent({
2103
+ relayUrl,
2104
+ gatewayId,
2105
+ gatewayToken,
2106
+ requestId,
2107
+ payload: {
2108
+ event: "error",
2109
+ error: String(error instanceof Error ? error.message : error),
2110
+ },
2111
+ });
2112
+ }
2113
+ return;
2114
+ }
2115
+
2116
+ const sessionKey = gatewayRequest.sessionKey;
2117
+ if (!sessionKey) {
2118
+ return;
2119
+ }
2120
+
2121
+ const localUrl = buildGatewayResponsesUrl(gatewayBaseUrl);
2122
+ const headers = {
2123
+ "content-type": "application/json",
2124
+ "x-openclaw-agent-id": gatewayRequest.agentId || "main",
2125
+ "x-openclaw-session-key": sessionKey,
2126
+ "x-openclaw-scopes": DEFAULT_GATEWAY_SCOPES,
2127
+ };
2128
+ if (gatewayAuthToken) {
2129
+ headers.authorization = `Bearer ${gatewayAuthToken}`;
2130
+ }
2131
+ if (gatewayRequest.stream) {
2132
+ headers.accept = "text/event-stream";
2133
+ }
2134
+
2135
+ const payload = gatewayRequest.request && typeof gatewayRequest.request === "object"
2136
+ ? gatewayRequest.request
2137
+ : {};
2138
+
2139
+ let response;
2140
+ try {
2141
+ response = await fetch(localUrl, {
2142
+ method: "POST",
2143
+ headers,
2144
+ body: JSON.stringify(payload),
2145
+ });
2146
+ } catch (error) {
2147
+ await publishRelayGatewayEvent({
2148
+ relayUrl,
2149
+ gatewayId,
2150
+ gatewayToken,
2151
+ requestId,
2152
+ payload: {
2153
+ event: "error",
2154
+ error: String(error instanceof Error ? error.message : error),
2155
+ },
2156
+ });
2157
+ return;
2158
+ }
2159
+
2160
+ const contentType = response.headers.get("content-type") ?? "";
2161
+
2162
+ if (!gatewayRequest.stream || !contentType.toLowerCase().includes("text/event-stream")) {
2163
+ const body = await response.text();
2164
+ await publishRelayGatewayEvent({
2165
+ relayUrl,
2166
+ gatewayId,
2167
+ gatewayToken,
2168
+ requestId,
2169
+ payload: {
2170
+ event: "response",
2171
+ statusCode: response.status,
2172
+ contentType,
2173
+ body,
2174
+ },
2175
+ });
2176
+ return;
2177
+ }
2178
+
2179
+ await publishRelayGatewayEvent({
2180
+ relayUrl,
2181
+ gatewayId,
2182
+ gatewayToken,
2183
+ requestId,
2184
+ payload: {
2185
+ event: "start",
2186
+ statusCode: response.status,
2187
+ contentType,
2188
+ },
2189
+ });
2190
+
2191
+ if (!response.body) {
2192
+ await publishRelayGatewayEvent({
2193
+ relayUrl,
2194
+ gatewayId,
2195
+ gatewayToken,
2196
+ requestId,
2197
+ payload: {
2198
+ event: "end",
2199
+ },
2200
+ });
2201
+ return;
2202
+ }
2203
+
2204
+ const reader = response.body.getReader();
2205
+ const decoder = new TextDecoder();
2206
+
2207
+ while (true) {
2208
+ const { done, value } = await reader.read();
2209
+ if (done) {
2210
+ break;
2211
+ }
2212
+ const text = decoder.decode(value, { stream: true });
2213
+ if (!text) {
2214
+ continue;
2215
+ }
2216
+ await publishRelayGatewayEvent({
2217
+ relayUrl,
2218
+ gatewayId,
2219
+ gatewayToken,
2220
+ requestId,
2221
+ payload: {
2222
+ event: "chunk",
2223
+ data: text,
2224
+ },
2225
+ });
2226
+ }
2227
+
2228
+ const tail = decoder.decode();
2229
+ if (tail) {
2230
+ await publishRelayGatewayEvent({
2231
+ relayUrl,
2232
+ gatewayId,
2233
+ gatewayToken,
2234
+ requestId,
2235
+ payload: {
2236
+ event: "chunk",
2237
+ data: tail,
2238
+ },
2239
+ });
2240
+ }
2241
+
2242
+ await publishRelayGatewayEvent({
2243
+ relayUrl,
2244
+ gatewayId,
2245
+ gatewayToken,
2246
+ requestId,
2247
+ payload: {
2248
+ event: "end",
2249
+ },
2250
+ });
2251
+
2252
+ if (!json) {
2253
+ console.log(`Relayed stream request: ${requestId}`);
2254
+ }
2255
+ }
2256
+
2257
+ async function runRelayBridge({
2258
+ relayUrl,
2259
+ gatewayId,
2260
+ gatewayToken,
2261
+ gatewayBaseUrl,
2262
+ gatewayAuthToken,
2263
+ json,
2264
+ }) {
2265
+ if (!relayUrl) {
2266
+ fail("relay URL is required for bridge mode");
2267
+ }
2268
+ if (!gatewayId) {
2269
+ fail(`relay gateway ID is missing. run \`${CLI_COMMAND_NAME} pair <code>\` first`);
2270
+ }
2271
+
2272
+ if (!json) {
2273
+ console.log(`Relay bridge online. gatewayId=${gatewayId}`);
2274
+ console.log(`Relay URL: ${relayUrl}`);
2275
+ console.log(`Local Gateway: ${gatewayBaseUrl}`);
2276
+ }
2277
+
2278
+ setGatewayWsBridgePublisher(async (event, payload) => {
2279
+ if (event !== "chat") {
2280
+ return;
2281
+ }
2282
+ await handleGatewayChatEvent({
2283
+ relayUrl,
2284
+ gatewayId,
2285
+ gatewayToken,
2286
+ gatewayBaseUrl,
2287
+ gatewayAuthToken,
2288
+ payload,
2289
+ });
2290
+ });
2291
+
2292
+ const activeRequests = new Set();
2293
+
2294
+ while (true) {
2295
+ const request = await readRelayNextGatewayRequest({
2296
+ relayUrl,
2297
+ gatewayId,
2298
+ gatewayToken,
2299
+ waitSeconds: 30,
2300
+ });
2301
+ if (!request) {
2302
+ continue;
2303
+ }
2304
+
2305
+ if (!request.requestId || !request.sessionKey) {
2306
+ continue;
2307
+ }
2308
+
2309
+ const requestKind = request.stream
2310
+ ? "stream"
2311
+ : parseRelayRpcRequest(request)?.method || "response";
2312
+ if (!json) {
2313
+ console.log(`Bridge request start: ${request.requestId} (${requestKind})`);
2314
+ }
2315
+
2316
+ const task = (async () => {
2317
+ try {
2318
+ await proxyGatewayRequestLocal({
2319
+ relayUrl,
2320
+ gatewayId,
2321
+ gatewayToken,
2322
+ gatewayBaseUrl,
2323
+ gatewayAuthToken,
2324
+ gatewayRequest: request,
2325
+ json,
2326
+ });
2327
+ } catch (error) {
2328
+ if (!json) {
2329
+ console.error(`bridge proxy error: ${String(error instanceof Error ? error.message : error)}`);
2330
+ }
2331
+ try {
2332
+ await publishRelayGatewayEvent({
2333
+ relayUrl,
2334
+ gatewayId,
2335
+ gatewayToken,
2336
+ requestId: request.requestId,
2337
+ payload: {
2338
+ event: "error",
2339
+ error: String(error instanceof Error ? error.message : error),
2340
+ },
2341
+ });
2342
+ } catch {
2343
+ // noop
2344
+ }
2345
+ } finally {
2346
+ if (!json) {
2347
+ console.log(`Bridge request end: ${request.requestId} (${requestKind})`);
2348
+ }
2349
+ }
2350
+ })();
2351
+
2352
+ activeRequests.add(task);
2353
+ task.finally(() => {
2354
+ activeRequests.delete(task);
2355
+ });
2356
+ }
2357
+ }
2358
+
2359
+ function startRelayBridgeDetached({
2360
+ relayUrl,
2361
+ gatewayBaseUrl,
2362
+ }) {
2363
+ const existing = listBridgeProcesses({ relayUrl, gatewayBaseUrl });
2364
+ if (existing.length > 0) {
2365
+ return { started: false, alreadyRunning: true, mode: "process", count: existing.length };
2366
+ }
2367
+
2368
+ const child = spawn(
2369
+ process.execPath,
2370
+ [CLI_ENTRY, "bridge", "--relay", relayUrl, "--gateway", gatewayBaseUrl],
2371
+ {
2372
+ detached: true,
2373
+ stdio: "ignore",
2374
+ env: { ...process.env },
2375
+ windowsHide: process.platform === "win32",
2376
+ },
2377
+ );
2378
+ child.unref();
2379
+ return { started: true, alreadyRunning: false, mode: "detached" };
2380
+ }
2381
+
2382
+ async function runPair({
2383
+ json,
2384
+ account,
2385
+ relay,
2386
+ noRelay,
2387
+ mode,
2388
+ code: explicitCode,
2389
+ gateway,
2390
+ waitBind,
2391
+ waitSeconds,
2392
+ serveRelay,
2393
+ }) {
2394
+ if (!explicitCode || !String(explicitCode).trim()) {
2395
+ fail(`pair code is required. Usage: ${CLI_COMMAND_NAME} pair <1234|123456|XXXX-XXXX>`);
2396
+ }
2397
+ const code = normalizeProvidedCode(explicitCode);
2398
+ const configPath = resolveOpenClawConfigPath();
2399
+ let config = readJsonFile(configPath);
2400
+ let section = readChannelSection(config);
2401
+ const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
2402
+ if (!relayUrl) {
2403
+ fail(
2404
+ [
2405
+ "relay URL is required.",
2406
+ "Relay is enabled but no URL is available.",
2407
+ `Use the built-in default (${DEFAULT_RELAY_URL}) or pass --relay <url> / TESTNEXTIM_RELAY_URL.`,
2408
+ ].join("\n"),
2409
+ );
2410
+ }
2411
+
2412
+ const currentSession = await readPairSessionFromRelay({ relayUrl, code });
2413
+ if (!currentSession) {
2414
+ fail(
2415
+ [
2416
+ `pair code not found in relay: ${code}`,
2417
+ "Please generate the code from mobile app first.",
2418
+ ].join("\n"),
2419
+ );
2420
+ }
2421
+ if (currentSession.consumed) {
2422
+ fail(`pair code has already been consumed: ${code}`);
2423
+ }
2424
+
2425
+ const accountId = normalizeAccountId(currentSession.accountId || account);
2426
+ const transportMode = normalizeChatTransportMode(
2427
+ mode,
2428
+ normalizeChatTransportMode(currentSession.transportMode, "relay"),
2429
+ );
2430
+ const clientUserId = String(currentSession.clientUserID ?? "").trim().toLowerCase();
2431
+ if (!clientUserId) {
2432
+ fail(
2433
+ [
2434
+ `pair code ${code} is missing clientUserId in relay session.`,
2435
+ "Please regenerate the pair code from the latest app and try again.",
2436
+ ].join("\n"),
2437
+ );
2438
+ }
2439
+
2440
+ const prepared = prepareConfig({
2441
+ config,
2442
+ });
2443
+ config = prepared.config;
2444
+ if (prepared.changed) {
2445
+ writeJsonFile(configPath, config);
2446
+ }
2447
+ section = readChannelSection(config);
2448
+ if (!json && prepared.changed) {
2449
+ console.log("Updated openclaw.json: removed stale testnextim plugin/channel config.");
2450
+ }
2451
+
2452
+ const bindPath = normalizePath(section.bindPath, DEFAULT_BIND_PATH);
2453
+
2454
+ const bindPublicBaseUrl =
2455
+ (typeof section.bindPublicBaseUrl === "string"
2456
+ ? section.bindPublicBaseUrl
2457
+ : "") ||
2458
+ process.env.OPENCLAW_TESTNEXTIM_PUBLIC_BASE_URL?.trim() ||
2459
+ process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
2460
+ "";
2461
+
2462
+ const localGatewayBaseUrl = resolveGatewayBaseUrl({ gateway }, config);
2463
+ const gatewayAuthToken = resolveGatewayAuthToken(config);
2464
+
2465
+ const localBindUrl = buildBindUrl({
2466
+ base: localGatewayBaseUrl,
2467
+ bindPath,
2468
+ accountId,
2469
+ code,
2470
+ });
2471
+
2472
+ const publicBindUrl = buildBindUrl({
2473
+ base: bindPublicBaseUrl,
2474
+ bindPath,
2475
+ accountId,
2476
+ code,
2477
+ });
2478
+ const relayBindUrl = currentSession.bindUrl || publicBindUrl || localBindUrl;
2479
+ if (!relayBindUrl) {
2480
+ fail("unable to determine bindUrl for this pair code");
2481
+ }
2482
+
2483
+ const ensured = ensureRelayBridgeCredentials({
2484
+ accountId,
2485
+ relayUrl,
2486
+ });
2487
+ section = ensured.section;
2488
+ const relayCredentials = ensured.credentials;
2489
+ if (relayCredentials?.gatewayId && relayCredentials?.gatewayToken) {
2490
+ const existingBinding = await readRelayGatewayBinding({
2491
+ relayUrl,
2492
+ gatewayId: relayCredentials.gatewayId,
2493
+ gatewayToken: relayCredentials.gatewayToken,
2494
+ });
2495
+ const existingCode =
2496
+ existingBinding?.code && /^[A-Za-z0-9-]+$/.test(existingBinding.code)
2497
+ ? normalizeProvidedCode(existingBinding.code)
2498
+ : "";
2499
+ if (existingBinding && existingCode !== code) {
2500
+ fail(
2501
+ [
2502
+ "This host is already bound to a device.",
2503
+ existingBinding.clientUserId
2504
+ ? `Current clientUserId: ${existingBinding.clientUserId}`
2505
+ : null,
2506
+ existingBinding.code ? `Active pair code: ${existingBinding.code}` : null,
2507
+ `Run \`${CLI_COMMAND_NAME} reset\` first to clear the current binding, then retry pairing.`,
2508
+ ]
2509
+ .filter(Boolean)
2510
+ .join("\n"),
2511
+ );
2512
+ }
2513
+ }
2514
+ const publishedGatewayBaseUrl = resolvePublishedGatewayBaseUrl({
2515
+ relayUrl,
2516
+ bindPublicBaseUrl,
2517
+ localGatewayBaseUrl,
2518
+ });
2519
+ const publishedGatewayToken =
2520
+ normalizeBaseUrl(publishedGatewayBaseUrl) === normalizeBaseUrl(relayUrl)
2521
+ ? ""
2522
+ : gatewayAuthToken;
2523
+
2524
+ const requiresGatewayRestart = prepared.changed;
2525
+ let restartResult = null;
2526
+ let gatewayProbe = null;
2527
+
2528
+ if (requiresGatewayRestart) {
2529
+ restartResult = restartOpenClaw();
2530
+ if (!restartResult.ok) {
2531
+ fail(
2532
+ [
2533
+ "OpenClaw restart failed after updating testnextim config.",
2534
+ "Run `openclaw daemon restart` and retry.",
2535
+ restartResult.stderr?.trim() || restartResult.stdout?.trim() || "",
2536
+ ]
2537
+ .filter(Boolean)
2538
+ .join("\n"),
2539
+ );
2540
+ }
2541
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2542
+ }
2543
+
2544
+ gatewayProbe = await probeGatewayResponsesEndpoint({
2545
+ gatewayBaseUrl: localGatewayBaseUrl,
2546
+ gatewayAuthToken,
2547
+ });
2548
+ if (!gatewayProbe.ok) {
2549
+ if (!restartResult) {
2550
+ restartResult = restartOpenClaw();
2551
+ if (!restartResult.ok) {
2552
+ fail(
2553
+ [
2554
+ "OpenClaw gateway is not ready and restart failed.",
2555
+ "Run `openclaw daemon restart` and retry.",
2556
+ restartResult.stderr?.trim() || restartResult.stdout?.trim() || "",
2557
+ ]
2558
+ .filter(Boolean)
2559
+ .join("\n"),
2560
+ );
2561
+ }
2562
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2563
+ gatewayProbe = await probeGatewayResponsesEndpoint({
2564
+ gatewayBaseUrl: localGatewayBaseUrl,
2565
+ gatewayAuthToken,
2566
+ });
2567
+ }
2568
+
2569
+ if (!gatewayProbe.ok) {
2570
+ fail(
2571
+ [
2572
+ "local OpenClaw gateway `/v1/responses` is not ready for relay bridge.",
2573
+ `Checked: ${gatewayProbe.url}`,
2574
+ gatewayProbe.status
2575
+ ? `HTTP ${gatewayProbe.status} (expected non-401/403/404)`
2576
+ : gatewayProbe.error || "request failed",
2577
+ "Make sure OpenClaw daemon is running and the responses endpoint is enabled.",
2578
+ ].join("\n"),
2579
+ );
2580
+ }
2581
+ }
2582
+
2583
+ const relayResult = await publishPairToRelay({
2584
+ relayUrl,
2585
+ code,
2586
+ transportMode,
2587
+ clientUserId,
2588
+ bindUrl: relayBindUrl,
2589
+ gatewayBaseUrl: publishedGatewayBaseUrl,
2590
+ gatewayToken: publishedGatewayToken,
2591
+ relayGatewayId: relayCredentials?.gatewayId,
2592
+ relayClientToken: relayCredentials?.clientToken,
2593
+ relayGatewayToken: relayCredentials?.gatewayToken,
2594
+ accountId,
2595
+ });
2596
+
2597
+ const output = {
2598
+ command: "pair",
2599
+ accountId,
2600
+ clientUserId,
2601
+ code,
2602
+ transportMode,
2603
+ bindPath,
2604
+ bindPublicBaseUrl: bindPublicBaseUrl || null,
2605
+ bindUrl: publicBindUrl,
2606
+ localBindUrl,
2607
+ relayBindUrl,
2608
+ relayUrl,
2609
+ relayLookupUrl: relayResult?.relayLookupUrl ?? null,
2610
+ relayWorkerEnabled: Boolean(serveRelay),
2611
+ relayWorkerResult: null,
2612
+ localGatewayBaseUrl,
2613
+ publishedGatewayBaseUrl,
2614
+ gatewayRestarted: Boolean(restartResult?.ok),
2615
+ relayGatewayId: relayCredentials?.gatewayId ?? null,
2616
+ };
2617
+
2618
+ if (serveRelay && !json) {
2619
+ try {
2620
+ const bridgeResult = startRelayBridgeDetached({
2621
+ relayUrl,
2622
+ gatewayBaseUrl: localGatewayBaseUrl,
2623
+ });
2624
+ output.relayWorkerResult = bridgeResult;
2625
+ } catch (error) {
2626
+ output.relayWorkerResult = {
2627
+ mode: "detached",
2628
+ started: false,
2629
+ error: String(error instanceof Error ? error.message : error),
2630
+ };
2631
+ console.warn(`Failed to start relay bridge worker: ${output.relayWorkerResult.error}`);
2632
+ }
2633
+ }
2634
+
2635
+ if (json) {
2636
+ console.log(JSON.stringify(output, null, 2));
2637
+ } else {
2638
+ console.log(`Pair code: ${code}`);
2639
+ console.log(`Transport Mode: ${transportMode}`);
2640
+ if (publicBindUrl) console.log(`Public Bind URL: ${publicBindUrl}`);
2641
+ console.log(`Local Bind URL: ${localBindUrl}`);
2642
+ console.log(`Relay Bind URL: ${relayBindUrl}`);
2643
+ console.log(`Published Gateway Base: ${publishedGatewayBaseUrl}`);
2644
+ if (relayResult?.relayLookupUrl) {
2645
+ console.log(`Relay Lookup: ${relayResult.relayLookupUrl}`);
2646
+ }
2647
+ if (relayCredentials?.gatewayId) {
2648
+ console.log(`Relay Gateway ID: ${relayCredentials.gatewayId}`);
2649
+ }
2650
+ if (clientUserId) {
2651
+ console.log(`Client User ID: ${clientUserId}`);
2652
+ }
2653
+ if (serveRelay) {
2654
+ if (output.relayWorkerResult?.started) {
2655
+ console.log("Relay bridge worker: started (detached)");
2656
+ } else if (output.relayWorkerResult?.alreadyRunning) {
2657
+ console.log(`Relay bridge worker: already running (${output.relayWorkerResult.mode})`);
2658
+ } else {
2659
+ console.log("Relay bridge worker: failed to start");
2660
+ }
2661
+ }
2662
+ console.log(code);
2663
+ }
2664
+ }
2665
+
2666
+ async function runReset({ json, relay, noRelay, gateway }) {
2667
+ const configPath = resolveOpenClawConfigPath();
2668
+ const config = readJsonFile(configPath);
2669
+ const section = readChannelSection(config);
2670
+ const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
2671
+ const gatewayBaseUrl = resolveGatewayBaseUrl({ gateway }, config);
2672
+ const accountId = normalizeAccountId(section.accountId);
2673
+ const creds = resolveRelayCredentialsFromSection(section);
2674
+
2675
+ let activeBinding = null;
2676
+ let relayResetResult = null;
2677
+ if (relayUrl && creds.gatewayId && creds.gatewayToken) {
2678
+ activeBinding = await readRelayGatewayBinding({
2679
+ relayUrl,
2680
+ gatewayId: creds.gatewayId,
2681
+ gatewayToken: creds.gatewayToken,
2682
+ });
2683
+ relayResetResult = await resetRelayGatewayBinding({
2684
+ relayUrl,
2685
+ gatewayId: creds.gatewayId,
2686
+ gatewayToken: creds.gatewayToken,
2687
+ });
2688
+ }
2689
+
2690
+ const stoppedBridges =
2691
+ relayUrl && gatewayBaseUrl
2692
+ ? stopBridgeProcesses({ relayUrl, gatewayBaseUrl })
2693
+ : { count: 0, pids: [] };
2694
+ const nextState = resetLocalBindingState({ accountId });
2695
+
2696
+ const output = {
2697
+ command: "reset",
2698
+ relayUrl: relayUrl || null,
2699
+ gatewayBaseUrl,
2700
+ previousGatewayId: creds.gatewayId || null,
2701
+ clearedBinding: activeBinding,
2702
+ relayReset:
2703
+ relayResetResult == null
2704
+ ? null
2705
+ : {
2706
+ ok: relayResetResult.ok,
2707
+ notFound: relayResetResult.notFound,
2708
+ clearedCodes: relayResetResult.clearedCodes,
2709
+ },
2710
+ stoppedBridgeCount: stoppedBridges.count,
2711
+ nextGatewayId: nextState.state.relayGatewayId ?? null,
2712
+ statePath: nextState.statePath,
2713
+ };
2714
+
2715
+ if (json) {
2716
+ console.log(JSON.stringify(output, null, 2));
2717
+ return;
2718
+ }
2719
+
2720
+ if (activeBinding) {
2721
+ console.log(
2722
+ `Cleared binding: ${activeBinding.clientUserId || "(unknown client)"}${
2723
+ activeBinding.code ? ` · code ${activeBinding.code}` : ""
2724
+ }`,
2725
+ );
2726
+ } else {
2727
+ console.log("No active binding found on relay.");
2728
+ }
2729
+ if (relayResetResult?.ok) {
2730
+ console.log("Relay binding reset: done");
2731
+ } else if (relayResetResult?.notFound) {
2732
+ console.log("Relay binding reset: nothing to clear");
2733
+ } else {
2734
+ console.log("Relay binding reset: skipped");
2735
+ }
2736
+ if (stoppedBridges.count > 0) {
2737
+ console.log(`Stopped relay bridge processes: ${stoppedBridges.count}`);
2738
+ }
2739
+ console.log(`Local state reset: ${nextState.statePath}`);
2740
+ console.log(`You can now run \`${CLI_COMMAND_NAME} pair <code>\` again.`);
2741
+ }
2742
+
2743
+ function runSetup({
2744
+ json,
2745
+ account,
2746
+ relay,
2747
+ mode,
2748
+ publicBase,
2749
+ restart,
2750
+ }) {
2751
+ const configPath = resolveOpenClawConfigPath();
2752
+ let config = readJsonFile(configPath);
2753
+ let section = readChannelSection(config);
2754
+ const relayUrl = resolveRelayUrl({ relay, noRelay: false }, section);
2755
+ if (!relayUrl) {
2756
+ fail(
2757
+ [
2758
+ "setup requires relay URL.",
2759
+ "Relay is enabled but no URL is available.",
2760
+ `Use the built-in default (${DEFAULT_RELAY_URL}) or pass --relay <url> / TESTNEXTIM_RELAY_URL.`,
2761
+ ].join("\n"),
2762
+ );
2763
+ }
2764
+ const accountId = normalizeAccountId(account);
2765
+
2766
+ const prepared = prepareConfig({
2767
+ config,
2768
+ });
2769
+ config = prepared.config;
2770
+
2771
+ const setupChanges = [...prepared.changes];
2772
+ if (prepared.changed) {
2773
+ writeJsonFile(configPath, config);
2774
+ }
2775
+ const persistedSettings = persistTestNextIMSettings({
2776
+ bindPublicBaseUrl: publicBase,
2777
+ transportMode: normalizeChatTransportMode(mode, "relay"),
2778
+ });
2779
+ section = readChannelSection(config);
2780
+
2781
+ if (relayUrl) {
2782
+ const ensured = ensureRelayBridgeCredentials({
2783
+ accountId: "default",
2784
+ relayUrl,
2785
+ });
2786
+ section = ensured.section;
2787
+ for (const item of ensured.changes) {
2788
+ setupChanges.push(item);
2789
+ }
2790
+ }
2791
+
2792
+ let restartResult = null;
2793
+ if (restart) {
2794
+ restartResult = restartOpenClaw();
2795
+ }
2796
+
2797
+ if (json) {
2798
+ console.log(
2799
+ JSON.stringify(
2800
+ {
2801
+ command: "setup",
2802
+ configPath,
2803
+ statePath: persistedSettings.statePath,
2804
+ changed: setupChanges.length > 0,
2805
+ changes: setupChanges,
2806
+ restarted: restart,
2807
+ accountId,
2808
+ relayUrl: resolveRelayUrl({ relay: null, noRelay: false }, section),
2809
+ transportMode: normalizeChatTransportMode(section.chatTransportMode, "relay"),
2810
+ restartResult: restartResult
2811
+ ? {
2812
+ ok: restartResult.ok,
2813
+ command: restartResult.command,
2814
+ status: restartResult.status ?? 0,
2815
+ }
2816
+ : null,
2817
+ },
2818
+ null,
2819
+ 2,
2820
+ ),
2821
+ );
2822
+ return;
2823
+ }
2824
+
2825
+ if (setupChanges.length > 0) {
2826
+ console.log(`Updated config: ${configPath}`);
2827
+ for (const item of setupChanges) {
2828
+ console.log(`- ${item}`);
2829
+ }
2830
+ } else {
2831
+ console.log(`Config already prepared: ${configPath}`);
2832
+ }
2833
+ console.log(`State file: ${persistedSettings.statePath}`);
2834
+ console.log(`Chat transport mode: ${normalizeChatTransportMode(section.chatTransportMode, "relay")}`);
2835
+ if (restart) {
2836
+ if (restartResult?.ok) {
2837
+ console.log("OpenClaw restarted.");
2838
+ } else {
2839
+ console.log("OpenClaw restart failed. You can run manually:");
2840
+ console.log(" openclaw daemon restart");
2841
+ if (restartResult?.stderr?.trim()) {
2842
+ console.log(restartResult.stderr.trim());
2843
+ }
2844
+ }
2845
+ }
2846
+ }
2847
+
2848
+ function runPairUrl({ json, account, code: explicitCode }) {
2849
+ const configPath = resolveOpenClawConfigPath();
2850
+ const config = readJsonFile(configPath);
2851
+ const section = readChannelSection(config);
2852
+ const relayUrl = resolveRelayUrl({ relay: null, noRelay: false }, section);
2853
+
2854
+ const accountId = normalizeAccountId(account);
2855
+ const code = explicitCode ? normalizeProvidedCode(explicitCode) : generatePairCode();
2856
+ const bindPath = normalizePath(section.bindPath, DEFAULT_BIND_PATH);
2857
+ const bindPublicBaseUrl =
2858
+ (typeof section.bindPublicBaseUrl === "string"
2859
+ ? section.bindPublicBaseUrl
2860
+ : "") ||
2861
+ process.env.OPENCLAW_TESTNEXTIM_PUBLIC_BASE_URL?.trim() ||
2862
+ process.env.OPENCLAW_CUSTOM_IM_PUBLIC_BASE_URL?.trim() ||
2863
+ "";
2864
+ const bindUrl = buildBindUrl({
2865
+ base: bindPublicBaseUrl,
2866
+ bindPath,
2867
+ accountId,
2868
+ code,
2869
+ });
2870
+ const relayBindUrl = relayUrl
2871
+ ? `${trimSlash(relayUrl)}/v1/pair-sessions/${encodeURIComponent(code)}/bind?${new URLSearchParams({ accountId, code }).toString()}`
2872
+ : null;
2873
+ const resolvedBindUrl = bindUrl || relayBindUrl;
2874
+ if (!resolvedBindUrl) {
2875
+ fail("unable to determine bindUrl");
2876
+ }
2877
+
2878
+ if (json) {
2879
+ console.log(
2880
+ JSON.stringify(
2881
+ {
2882
+ command: "pair-url",
2883
+ accountId,
2884
+ code,
2885
+ bindUrl: resolvedBindUrl,
2886
+ },
2887
+ null,
2888
+ 2,
2889
+ ),
2890
+ );
2891
+ return;
2892
+ }
2893
+
2894
+ console.log(resolvedBindUrl);
2895
+ }
2896
+
2897
+ async function runBridge({ json, relay, noRelay, gateway }) {
2898
+ const configPath = resolveOpenClawConfigPath();
2899
+ const config = readJsonFile(configPath);
2900
+ const section = readChannelSection(config);
2901
+ const relayUrl = resolveRelayUrl({ relay, noRelay }, section);
2902
+ const creds = resolveRelayCredentialsFromSection(section);
2903
+ const gatewayBaseUrl = resolveGatewayBaseUrl({ gateway }, config);
2904
+ const gatewayAuthToken = resolveGatewayAuthToken(config);
2905
+
2906
+ if (!relayUrl) {
2907
+ fail(
2908
+ [
2909
+ "relay URL is required for bridge mode.",
2910
+ "Relay is enabled but no URL is available.",
2911
+ `Use the built-in default (${DEFAULT_RELAY_URL}) or pass --relay <url> / TESTNEXTIM_RELAY_URL.`,
2912
+ ].join("\n"),
2913
+ );
2914
+ }
2915
+ if (!creds.gatewayId) {
2916
+ fail(`relay gateway ID is missing. run \`${CLI_COMMAND_NAME} pair <code>\` first`);
2917
+ }
2918
+ if (!creds.gatewayToken) {
2919
+ fail(`relay gateway token is missing. run \`${CLI_COMMAND_NAME} pair <code>\` first`);
2920
+ }
2921
+
2922
+ const bridgeLock = acquireBridgeLock({
2923
+ relayUrl,
2924
+ gatewayId: creds.gatewayId,
2925
+ gatewayBaseUrl,
2926
+ });
2927
+ if (!bridgeLock.acquired) {
2928
+ if (json) {
2929
+ console.log(
2930
+ JSON.stringify(
2931
+ {
2932
+ command: "bridge",
2933
+ status: "already_running",
2934
+ relayUrl,
2935
+ gatewayId: creds.gatewayId,
2936
+ gatewayBaseUrl,
2937
+ ownerPid: bridgeLock.ownerPid ?? null,
2938
+ lockPath: bridgeLock.lockPath,
2939
+ },
2940
+ null,
2941
+ 2,
2942
+ ),
2943
+ );
2944
+ } else {
2945
+ console.log(
2946
+ [
2947
+ "Relay bridge already running for this gateway.",
2948
+ `relay=${relayUrl}`,
2949
+ `gateway=${gatewayBaseUrl}`,
2950
+ bridgeLock.ownerPid ? `owner pid=${bridgeLock.ownerPid}` : null,
2951
+ ]
2952
+ .filter(Boolean)
2953
+ .join(" "),
2954
+ );
2955
+ }
2956
+ return;
2957
+ }
2958
+
2959
+ const cleanupBridgeLock = () => {
2960
+ try {
2961
+ bridgeLock.release?.();
2962
+ } catch {}
2963
+ };
2964
+ process.on("exit", cleanupBridgeLock);
2965
+ process.on("SIGINT", () => {
2966
+ cleanupBridgeLock();
2967
+ process.exit(0);
2968
+ });
2969
+ process.on("SIGTERM", () => {
2970
+ cleanupBridgeLock();
2971
+ process.exit(0);
2972
+ });
2973
+ process.on("SIGHUP", () => {
2974
+ cleanupBridgeLock();
2975
+ process.exit(0);
2976
+ });
2977
+
2978
+ try {
2979
+ if (json) {
2980
+ console.log(
2981
+ JSON.stringify(
2982
+ {
2983
+ command: "bridge",
2984
+ status: "running",
2985
+ relayUrl,
2986
+ gatewayId: creds.gatewayId,
2987
+ gatewayBaseUrl,
2988
+ lockPath: bridgeLock.lockPath,
2989
+ },
2990
+ null,
2991
+ 2,
2992
+ ),
2993
+ );
2994
+ }
2995
+
2996
+ await runRelayBridge({
2997
+ relayUrl,
2998
+ gatewayId: creds.gatewayId,
2999
+ gatewayToken: creds.gatewayToken,
3000
+ gatewayBaseUrl,
3001
+ gatewayAuthToken,
3002
+ json,
3003
+ });
3004
+ } finally {
3005
+ cleanupBridgeLock();
3006
+ }
3007
+ }
3008
+
3009
+ function runStatus({ json }) {
3010
+ const configPath = resolveOpenClawConfigPath();
3011
+ const config = readJsonFile(configPath);
3012
+ const section = readChannelSection(config);
3013
+ const creds = resolveRelayCredentialsFromSection(section);
3014
+
3015
+ const status = {
3016
+ configPath,
3017
+ statePath: resolveTestNextIMStatePath(),
3018
+ channel: "testnextim",
3019
+ enabled: section?.enabled !== false,
3020
+ name: section?.name ?? "TestNextIM",
3021
+ bindPublicBaseUrl:
3022
+ typeof section?.bindPublicBaseUrl === "string" ? section.bindPublicBaseUrl : null,
3023
+ relayUrl: resolveRelayUrl({ relay: null, noRelay: false }, section),
3024
+ transportMode: normalizeChatTransportMode(section?.chatTransportMode, "relay"),
3025
+ gatewayBaseUrl: resolveGatewayBaseUrl({ gateway: null }, config),
3026
+ webhookPath: normalizePath(section?.webhookPath, DEFAULT_WEBHOOK_PATH),
3027
+ bindPath: normalizePath(section?.bindPath, DEFAULT_BIND_PATH),
3028
+ hasAppSecret: Boolean(resolveAppSecret(section)),
3029
+ hasAccessToken: Boolean(
3030
+ typeof section?.accessToken === "string" && section.accessToken.trim(),
3031
+ ),
3032
+ relayGatewayId: creds.gatewayId || null,
3033
+ hasRelayClientToken: Boolean(creds.clientToken),
3034
+ hasRelayGatewayToken: Boolean(creds.gatewayToken),
3035
+ linkedUserId:
3036
+ typeof section?.linkedUserId === "string" && section.linkedUserId.trim()
3037
+ ? section.linkedUserId.trim()
3038
+ : null,
3039
+ };
3040
+
3041
+ if (json) {
3042
+ console.log(JSON.stringify(status, null, 2));
3043
+ return;
3044
+ }
3045
+
3046
+ console.log(`Config: ${status.configPath}`);
3047
+ console.log(`Channel: ${status.channel}`);
3048
+ console.log(`Enabled: ${status.enabled ? "yes" : "no"}`);
3049
+ console.log(`Bind Base: ${status.bindPublicBaseUrl ?? "(not set)"}`);
3050
+ console.log(`Relay URL: ${status.relayUrl ?? "(not set)"}`);
3051
+ console.log(`Transport Mode: ${status.transportMode}`);
3052
+ console.log(`Local Gateway Base: ${status.gatewayBaseUrl}`);
3053
+ console.log(`Webhook Path: ${status.webhookPath}`);
3054
+ console.log(`Bind Path: ${status.bindPath}`);
3055
+ console.log(`Relay Gateway ID: ${status.relayGatewayId ?? "(missing)"}`);
3056
+ console.log(`Relay Client Token: ${status.hasRelayClientToken ? "set" : "missing"}`);
3057
+ console.log(`Relay Gateway Token: ${status.hasRelayGatewayToken ? "set" : "missing"}`);
3058
+ console.log(`App Secret: ${status.hasAppSecret ? "set" : "missing"}`);
3059
+ console.log(`Access Token: ${status.hasAccessToken ? "set" : "missing"}`);
3060
+ console.log(`Linked User: ${status.linkedUserId ?? "(not linked)"}`);
3061
+ }
3062
+
3063
+ async function main() {
3064
+ const { command, options } = parseArgs(process.argv.slice(2));
3065
+ switch (command) {
3066
+ case "setup":
3067
+ case "prepare":
3068
+ runSetup(options);
3069
+ return;
3070
+ case "pair":
3071
+ await runPair(options);
3072
+ return;
3073
+ case "reset":
3074
+ await runReset(options);
3075
+ return;
3076
+ case "bridge":
3077
+ await runBridge(options);
3078
+ return;
3079
+ case "pair-url":
3080
+ runPairUrl(options);
3081
+ return;
3082
+ case "status":
3083
+ runStatus(options);
3084
+ return;
3085
+ case "help":
3086
+ default:
3087
+ printHelp();
3088
+ }
3089
+ }
3090
+
3091
+ main().catch((error) => {
3092
+ fail(String(error instanceof Error ? error.message : error));
3093
+ });