mateclaw-openclaw-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/package.json +36 -0
  4. package/src/cli.mjs +2257 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,2257 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import https from 'node:https';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { URL } from 'node:url';
12
+ import qrcode from 'qrcode-terminal';
13
+ import { WebSocket } from 'ws';
14
+
15
+ const DEFAULT_OPENCLAW_URL = 'http://127.0.0.1:18789';
16
+ const DEFAULT_CHAT_PATH = '/v1/chat/completions';
17
+ const DEFAULT_CHAT_MODE = 'gateway-session';
18
+ const DEFAULT_SESSION_KEY = 'agent:main:main';
19
+ const CHAT_PATH_FALLBACKS = [
20
+ '/v1/chat/completions',
21
+ '/api/v1/chat/completions',
22
+ '/chat/completions',
23
+ '/v1/responses',
24
+ ];
25
+ const DEFAULT_LISTEN_HOST = '0.0.0.0';
26
+ const DEFAULT_PORT = 18890;
27
+ const DEFAULT_AGENT_ID = 'main';
28
+ const DEFAULT_EXPIRES_MINUTES = 720;
29
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 180000;
30
+ const PAYLOAD_TYPE = 'mateclaw_local_openclaw_demo';
31
+ const DEFAULT_OPENCLAW_BIN = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw';
32
+
33
+ async function main() {
34
+ const rawArgs = process.argv.slice(2);
35
+ const resolved = resolveCommand(rawArgs);
36
+ const options = parseArgs(resolved.args);
37
+
38
+ switch (resolved.command) {
39
+ case 'connect': {
40
+ const config = buildConfig(options);
41
+ await startConnector(config);
42
+ return;
43
+ }
44
+ case 'install': {
45
+ await runInstall(options);
46
+ return;
47
+ }
48
+ case 'doctor': {
49
+ await runDoctor(options);
50
+ return;
51
+ }
52
+ case 'help':
53
+ printHelp();
54
+ return;
55
+ default:
56
+ printHelp();
57
+ process.exitCode = 1;
58
+ }
59
+ }
60
+
61
+ function resolveCommand(rawArgs) {
62
+ if (!rawArgs.length) {
63
+ return { command: 'help', args: [] };
64
+ }
65
+ const [first, second, ...rest] = rawArgs;
66
+ const normalizedFirst = `${first || ''}`.trim().toLowerCase();
67
+ const normalizedSecond = `${second || ''}`.trim().toLowerCase();
68
+ if (normalizedFirst === 'login' && normalizedSecond === 'install') {
69
+ return { command: 'install', args: rest };
70
+ }
71
+ if (normalizedFirst === 'help' || normalizedFirst === '--help' || normalizedFirst === '-h') {
72
+ return { command: 'help', args: [] };
73
+ }
74
+ return { command: normalizedFirst, args: [second, ...rest].filter((v) => v != null) };
75
+ }
76
+
77
+ function printHelp() {
78
+ console.log('Usage: node ./src/cli.mjs <command> [options]');
79
+ console.log('');
80
+ console.log('Commands:');
81
+ console.log(' install One-command setup: check OpenClaw, auto-fix config, restart gateway, start connector');
82
+ console.log(' login install Alias of install (for folotoy-like UX)');
83
+ console.log(' connect Start connector directly (no config repair)');
84
+ console.log(' doctor Check OpenClaw and local config health');
85
+ console.log('');
86
+ console.log('Shared options (connect/install):');
87
+ console.log(' --openclaw-url <url> Upstream OpenClaw base URL');
88
+ console.log(' --chat-path <path> Chat path on OpenClaw');
89
+ console.log(' --host <host> Connector bind host');
90
+ console.log(' --port <port> Connector bind port');
91
+ console.log(' --lan-host <host> Override LAN host shown in QR payload');
92
+ console.log(' --agent-id <id> Agent id attached to requests');
93
+ console.log(' --name <label> Display name shown in the app');
94
+ console.log(' --token <token> Fixed bearer token for the app');
95
+ console.log(' --expires-minutes <n> QR/token expiration window');
96
+ console.log(' --upstream-token <t> Bearer token forwarded to OpenClaw');
97
+ console.log(' --upstream-timeout-ms <ms> Upstream OpenClaw request timeout');
98
+ console.log(' --chat-mode <mode> gateway-session | http-proxy');
99
+ console.log(' --session-key <key> Fixed Gateway session key (default: agent:<agent-id>:main)');
100
+ console.log('');
101
+ console.log('Install/Doctor options:');
102
+ console.log(' --openclaw-bin <cmd> OpenClaw CLI command (default: openclaw/openclaw.cmd)');
103
+ console.log(' --config-path <path> Override openclaw.json path');
104
+ console.log(' --skip-gateway-restart Skip openclaw gateway restart after config updates');
105
+ console.log(' --skip-connector Only setup config, do not start local connector');
106
+ console.log(' --dry-run Validate and print changes without writing files');
107
+ }
108
+
109
+ function parseArgs(args) {
110
+ const parsed = {};
111
+ for (let i = 0; i < args.length; i += 1) {
112
+ const arg = args[i];
113
+ if (!arg.startsWith('--')) {
114
+ continue;
115
+ }
116
+
117
+ const key = arg.slice(2);
118
+ const next = args[i + 1];
119
+ if (next == null || next.startsWith('--')) {
120
+ parsed[key] = 'true';
121
+ continue;
122
+ }
123
+
124
+ parsed[key] = next;
125
+ i += 1;
126
+ }
127
+ return parsed;
128
+ }
129
+
130
+ function optionEnabled(options, key) {
131
+ const raw = options?.[key];
132
+ if (raw == null) return false;
133
+ const normalized = `${raw}`.trim().toLowerCase();
134
+ return normalized === '' || normalized === 'true' || normalized === '1' || normalized === 'yes';
135
+ }
136
+
137
+ function resolveOpenClawBin(options) {
138
+ return `${options?.['openclaw-bin'] || process.env.OPENCLAW_BIN || DEFAULT_OPENCLAW_BIN}`.trim();
139
+ }
140
+
141
+ function resolveOpenClawConfigPath(options) {
142
+ const configured =
143
+ options?.['config-path'] ||
144
+ process.env.OPENCLAW_CONFIG_PATH ||
145
+ path.join(os.homedir(), '.openclaw', 'openclaw.json');
146
+ return path.resolve(configured);
147
+ }
148
+
149
+ function resolveIdentityPaths() {
150
+ const baseDir = path.join(os.homedir(), '.openclaw', 'identity');
151
+ return {
152
+ baseDir,
153
+ identityPath: path.join(baseDir, 'device.json'),
154
+ deviceAuthPath: path.join(baseDir, 'device-auth.json'),
155
+ };
156
+ }
157
+
158
+ function readJsonFileSafe(filePath) {
159
+ if (!fs.existsSync(filePath)) {
160
+ return {
161
+ exists: false,
162
+ valid: false,
163
+ value: null,
164
+ error: '',
165
+ };
166
+ }
167
+
168
+ try {
169
+ const raw = fs.readFileSync(filePath, 'utf8');
170
+ const value = JSON.parse(raw);
171
+ return {
172
+ exists: true,
173
+ valid: true,
174
+ value,
175
+ error: '',
176
+ };
177
+ } catch (error) {
178
+ return {
179
+ exists: true,
180
+ valid: false,
181
+ value: null,
182
+ error: `${error?.message || error}`,
183
+ };
184
+ }
185
+ }
186
+
187
+ function writeJsonFile(filePath, value) {
188
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
189
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
190
+ }
191
+
192
+ function deepClonePlain(value) {
193
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
194
+ return {};
195
+ }
196
+ return JSON.parse(JSON.stringify(value));
197
+ }
198
+
199
+ function timestampTagForFile() {
200
+ const now = new Date();
201
+ const yyyy = `${now.getFullYear()}`;
202
+ const mm = `${now.getMonth() + 1}`.padStart(2, '0');
203
+ const dd = `${now.getDate()}`.padStart(2, '0');
204
+ const hh = `${now.getHours()}`.padStart(2, '0');
205
+ const mi = `${now.getMinutes()}`.padStart(2, '0');
206
+ const ss = `${now.getSeconds()}`.padStart(2, '0');
207
+ return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
208
+ }
209
+
210
+ function backupFile(filePath, dryRun) {
211
+ if (!fs.existsSync(filePath)) {
212
+ return '';
213
+ }
214
+ const backupPath = `${filePath}.bak.${timestampTagForFile()}`;
215
+ if (!dryRun) {
216
+ fs.copyFileSync(filePath, backupPath);
217
+ }
218
+ return backupPath;
219
+ }
220
+
221
+ function runOpenClawCommand(bin, args, timeoutMs = 20000) {
222
+ try {
223
+ const result = spawnSync(bin, args, {
224
+ encoding: 'utf8',
225
+ timeout: timeoutMs,
226
+ shell: process.platform === 'win32',
227
+ });
228
+ const stdout = `${result.stdout || ''}`.trim();
229
+ const stderr = `${result.stderr || ''}`.trim();
230
+ const status = typeof result.status === 'number' ? result.status : 1;
231
+ const error = result.error ? `${result.error?.message || result.error}` : '';
232
+ return {
233
+ ok: status === 0 && !error,
234
+ status,
235
+ stdout,
236
+ stderr,
237
+ error,
238
+ };
239
+ } catch (error) {
240
+ return {
241
+ ok: false,
242
+ status: 1,
243
+ stdout: '',
244
+ stderr: '',
245
+ error: `${error?.message || error}`,
246
+ };
247
+ }
248
+ }
249
+
250
+ function commandLooksMissing(result) {
251
+ const blob = `${result?.stderr || ''}\n${result?.error || ''}`.toLowerCase();
252
+ return (
253
+ blob.includes('not recognized') ||
254
+ blob.includes('not found') ||
255
+ blob.includes('enoent') ||
256
+ blob.includes('cannot find')
257
+ );
258
+ }
259
+
260
+ function summarizeCommandFailure(result) {
261
+ if (!result) return 'unknown error';
262
+ const detail = [result.error, result.stderr, result.stdout].filter((part) => part).join(' | ');
263
+ return detail || `exit code ${result.status}`;
264
+ }
265
+
266
+ function extractGatewayTokenFromConfig(config) {
267
+ const mode = `${config?.gateway?.auth?.mode || ''}`.trim().toLowerCase();
268
+ if (mode === 'password') {
269
+ return `${config?.gateway?.auth?.password || ''}`.trim();
270
+ }
271
+ return `${config?.gateway?.auth?.token || ''}`.trim();
272
+ }
273
+
274
+ function maskToken(token) {
275
+ const text = `${token || ''}`.trim();
276
+ if (!text) return '';
277
+ if (text.length <= 8) return `${text.slice(0, 2)}***`;
278
+ return `${text.slice(0, 4)}...${text.slice(-2)}`;
279
+ }
280
+
281
+ function parsePortOrDefault(raw, fallback) {
282
+ const parsed = Number.parseInt(`${raw ?? ''}`, 10);
283
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
284
+ return parsed;
285
+ }
286
+
287
+ function createDoctorCheck(id, status, summary, detail = '') {
288
+ return { id, status, summary, detail };
289
+ }
290
+
291
+ function countDoctorFailures(checks) {
292
+ return checks.filter((item) => item.status === 'fail').length;
293
+ }
294
+
295
+ function countDoctorWarnings(checks) {
296
+ return checks.filter((item) => item.status === 'warn').length;
297
+ }
298
+
299
+ async function isPortAvailable(port, host) {
300
+ return await new Promise((resolve) => {
301
+ const server = net.createServer();
302
+ server.unref();
303
+ server.once('error', () => {
304
+ resolve(false);
305
+ });
306
+ server.once('listening', () => {
307
+ server.close(() => resolve(true));
308
+ });
309
+ server.listen(port, host);
310
+ });
311
+ }
312
+
313
+ async function pickAvailablePort(preferredPort, host, window = 20) {
314
+ const tried = [];
315
+ for (let step = 0; step <= window; step += 1) {
316
+ const port = preferredPort + step;
317
+ const available = await isPortAvailable(port, host);
318
+ tried.push({ port, available });
319
+ if (available) {
320
+ return {
321
+ found: true,
322
+ selectedPort: port,
323
+ tried,
324
+ };
325
+ }
326
+ }
327
+ return {
328
+ found: false,
329
+ selectedPort: preferredPort,
330
+ tried,
331
+ };
332
+ }
333
+
334
+ function sleep(ms) {
335
+ return new Promise((resolve) => setTimeout(resolve, ms));
336
+ }
337
+
338
+ async function probeGatewayHealth(openclawUrl, timeoutMs = 5000) {
339
+ const url = new URL('/health', `${stripTrailingSlash(openclawUrl)}/`);
340
+ try {
341
+ const result = await httpRequest({
342
+ url,
343
+ method: 'GET',
344
+ headers: {
345
+ accept: 'application/json',
346
+ },
347
+ timeoutMs,
348
+ });
349
+ const ok = result.statusCode >= 200 && result.statusCode < 300;
350
+ return {
351
+ ok,
352
+ statusCode: result.statusCode,
353
+ detail: ok ? 'reachable' : `http ${result.statusCode}`,
354
+ };
355
+ } catch (error) {
356
+ return {
357
+ ok: false,
358
+ statusCode: 0,
359
+ detail: `${error?.message || error}`,
360
+ };
361
+ }
362
+ }
363
+
364
+ async function waitForGatewayHealthy(openclawUrl, timeoutMs = 20000) {
365
+ const startedAt = Date.now();
366
+ while (Date.now() - startedAt < timeoutMs) {
367
+ const probe = await probeGatewayHealth(openclawUrl, 3000);
368
+ if (probe.ok) {
369
+ return probe;
370
+ }
371
+ await sleep(1000);
372
+ }
373
+ return {
374
+ ok: false,
375
+ statusCode: 0,
376
+ detail: `gateway health did not recover within ${timeoutMs}ms`,
377
+ };
378
+ }
379
+
380
+ async function startGatewayIfNeeded(openclawBin, openclawUrl) {
381
+ const health = await probeGatewayHealth(openclawUrl, 3000);
382
+ if (health.ok) {
383
+ return {
384
+ attempted: false,
385
+ ok: true,
386
+ detail: 'gateway already healthy',
387
+ };
388
+ }
389
+
390
+ const startResult = runOpenClawCommand(openclawBin, ['gateway', 'start'], 30000);
391
+ if (!startResult.ok) {
392
+ return {
393
+ attempted: true,
394
+ ok: false,
395
+ detail: summarizeCommandFailure(startResult),
396
+ };
397
+ }
398
+
399
+ const recovered = await waitForGatewayHealthy(openclawUrl, 20000);
400
+ return {
401
+ attempted: true,
402
+ ok: recovered.ok,
403
+ detail: recovered.ok ? 'gateway health recovered after start' : recovered.detail,
404
+ };
405
+ }
406
+
407
+ async function collectDoctorReport(options) {
408
+ const openclawBin = resolveOpenClawBin(options);
409
+ const configPath = resolveOpenClawConfigPath(options);
410
+ const openclawUrl = stripTrailingSlash(
411
+ options['openclaw-url'] ||
412
+ process.env.OPENCLAW_BASE_URL ||
413
+ DEFAULT_OPENCLAW_URL,
414
+ );
415
+ const configSnapshot = readJsonFileSafe(configPath);
416
+ const configValue = configSnapshot.valid ? configSnapshot.value : {};
417
+ const gatewayTokenFromConfig = extractGatewayTokenFromConfig(configValue);
418
+ const deviceAuthToken = detectLocalDeviceAuthToken();
419
+ const deviceIdentity = loadLocalDeviceIdentity();
420
+ const cliToken = `${options['upstream-token'] || ''}`.trim();
421
+ const envToken = `${process.env.OPENCLAW_UPSTREAM_TOKEN || ''}`.trim();
422
+ const effectiveToken = cliToken || deviceAuthToken || gatewayTokenFromConfig || envToken;
423
+ const { identityPath, deviceAuthPath } = resolveIdentityPaths();
424
+
425
+ const versionProbe = runOpenClawCommand(openclawBin, ['--version'], 10000);
426
+ const fallbackVersionProbe = !versionProbe.ok ?
427
+ runOpenClawCommand(openclawBin, ['version'], 10000) :
428
+ null;
429
+ const versionResult = versionProbe.ok ? versionProbe : (fallbackVersionProbe || versionProbe);
430
+
431
+ const gatewayStatusResult = runOpenClawCommand(openclawBin, ['gateway', 'status', '--json'], 45000);
432
+ const gatewayHealth = await waitForGatewayHealthy(openclawUrl, 8000);
433
+
434
+ const listenHost = options.host || process.env.MATECLAW_CONNECTOR_HOST || DEFAULT_LISTEN_HOST;
435
+ const preferredPort = parsePortOrDefault(
436
+ options.port || process.env.MATECLAW_CONNECTOR_PORT || `${DEFAULT_PORT}`,
437
+ DEFAULT_PORT,
438
+ );
439
+ const portAvailable = await isPortAvailable(preferredPort, listenHost);
440
+
441
+ const lanCandidates = listLanCandidates();
442
+ const lanHost = options['lan-host'] || process.env.MATECLAW_CONNECTOR_LAN_HOST || lanCandidates[0]?.address || '';
443
+
444
+ const checks = [];
445
+
446
+ if (!versionResult.ok && commandLooksMissing(versionResult)) {
447
+ checks.push(
448
+ createDoctorCheck(
449
+ 'openclaw-bin',
450
+ 'fail',
451
+ `OpenClaw CLI not found: ${openclawBin}`,
452
+ 'Install OpenClaw first or pass --openclaw-bin with a valid command path.',
453
+ ),
454
+ );
455
+ } else if (!versionResult.ok) {
456
+ checks.push(
457
+ createDoctorCheck(
458
+ 'openclaw-bin',
459
+ 'warn',
460
+ `OpenClaw CLI execution failed: ${openclawBin}`,
461
+ summarizeCommandFailure(versionResult),
462
+ ),
463
+ );
464
+ } else {
465
+ checks.push(
466
+ createDoctorCheck(
467
+ 'openclaw-bin',
468
+ 'pass',
469
+ `OpenClaw CLI available: ${openclawBin}`,
470
+ '',
471
+ ),
472
+ );
473
+ checks.push(
474
+ createDoctorCheck(
475
+ 'openclaw-version',
476
+ 'pass',
477
+ `OpenClaw version: ${(versionResult.stdout || versionResult.stderr || 'unknown').split('\n')[0]}`,
478
+ '',
479
+ ),
480
+ );
481
+ }
482
+
483
+ if (!configSnapshot.exists) {
484
+ checks.push(
485
+ createDoctorCheck(
486
+ 'config',
487
+ 'warn',
488
+ `Config missing: ${configPath}`,
489
+ 'Install will create a minimal openclaw.json.',
490
+ ),
491
+ );
492
+ } else if (!configSnapshot.valid) {
493
+ checks.push(
494
+ createDoctorCheck(
495
+ 'config',
496
+ 'fail',
497
+ `Config parse failed: ${configPath}`,
498
+ configSnapshot.error,
499
+ ),
500
+ );
501
+ } else {
502
+ checks.push(
503
+ createDoctorCheck(
504
+ 'config',
505
+ 'pass',
506
+ `Config loaded: ${configPath}`,
507
+ '',
508
+ ),
509
+ );
510
+ }
511
+
512
+ if (effectiveToken) {
513
+ const source = cliToken ?
514
+ 'explicit --upstream-token' :
515
+ (deviceAuthToken ? 'device-auth.json' : (gatewayTokenFromConfig ? 'openclaw.json gateway.auth' : 'OPENCLAW_UPSTREAM_TOKEN'));
516
+ checks.push(
517
+ createDoctorCheck(
518
+ 'token',
519
+ 'pass',
520
+ `Gateway token available (${source}): ${maskToken(effectiveToken)}`,
521
+ '',
522
+ ),
523
+ );
524
+ } else {
525
+ checks.push(
526
+ createDoctorCheck(
527
+ 'token',
528
+ 'warn',
529
+ 'No upstream auth token found.',
530
+ 'Install will auto-generate gateway.auth.token and sync device-auth token.',
531
+ ),
532
+ );
533
+ }
534
+
535
+ if (deviceIdentity) {
536
+ checks.push(
537
+ createDoctorCheck(
538
+ 'identity',
539
+ 'pass',
540
+ `Device identity ready: ${identityPath}`,
541
+ '',
542
+ ),
543
+ );
544
+ } else {
545
+ checks.push(
546
+ createDoctorCheck(
547
+ 'identity',
548
+ 'warn',
549
+ `Device identity missing: ${identityPath}`,
550
+ 'Install will auto-create device identity.',
551
+ ),
552
+ );
553
+ }
554
+
555
+ if (deviceAuthToken) {
556
+ checks.push(
557
+ createDoctorCheck(
558
+ 'device-auth',
559
+ 'pass',
560
+ `Device auth token ready: ${deviceAuthPath}`,
561
+ '',
562
+ ),
563
+ );
564
+ } else {
565
+ checks.push(
566
+ createDoctorCheck(
567
+ 'device-auth',
568
+ 'warn',
569
+ `Device auth token missing: ${deviceAuthPath}`,
570
+ 'Install will sync operator token into device-auth store.',
571
+ ),
572
+ );
573
+ }
574
+
575
+ if (gatewayStatusResult.ok) {
576
+ checks.push(
577
+ createDoctorCheck(
578
+ 'gateway-status',
579
+ 'pass',
580
+ 'OpenClaw gateway status command is healthy.',
581
+ '',
582
+ ),
583
+ );
584
+ } else {
585
+ checks.push(
586
+ createDoctorCheck(
587
+ 'gateway-status',
588
+ 'warn',
589
+ 'OpenClaw gateway status command returned non-zero.',
590
+ summarizeCommandFailure(gatewayStatusResult),
591
+ ),
592
+ );
593
+ }
594
+
595
+ if (gatewayHealth.ok) {
596
+ checks.push(
597
+ createDoctorCheck(
598
+ 'gateway-health',
599
+ 'pass',
600
+ `Gateway health reachable: ${openclawUrl}/health`,
601
+ '',
602
+ ),
603
+ );
604
+ } else {
605
+ checks.push(
606
+ createDoctorCheck(
607
+ 'gateway-health',
608
+ 'fail',
609
+ `Gateway health check failed: ${openclawUrl}/health`,
610
+ gatewayHealth.detail,
611
+ ),
612
+ );
613
+ }
614
+
615
+ if (lanHost) {
616
+ checks.push(
617
+ createDoctorCheck(
618
+ 'lan',
619
+ 'pass',
620
+ `LAN host selected: ${lanHost}`,
621
+ '',
622
+ ),
623
+ );
624
+ } else {
625
+ checks.push(
626
+ createDoctorCheck(
627
+ 'lan',
628
+ 'fail',
629
+ 'No LAN IPv4 address detected.',
630
+ 'Pass --lan-host manually.',
631
+ ),
632
+ );
633
+ }
634
+
635
+ if (portAvailable) {
636
+ checks.push(
637
+ createDoctorCheck(
638
+ 'port',
639
+ 'pass',
640
+ `Connector port available: ${listenHost}:${preferredPort}`,
641
+ '',
642
+ ),
643
+ );
644
+ } else {
645
+ checks.push(
646
+ createDoctorCheck(
647
+ 'port',
648
+ 'warn',
649
+ `Connector port is busy: ${listenHost}:${preferredPort}`,
650
+ 'Install will auto-select a nearby free port.',
651
+ ),
652
+ );
653
+ }
654
+
655
+ return {
656
+ checks,
657
+ openclawBin,
658
+ openclawUrl,
659
+ configPath,
660
+ preferredPort,
661
+ listenHost,
662
+ };
663
+ }
664
+
665
+ function printDoctorReport(report, title = 'OpenClaw Doctor') {
666
+ console.log('');
667
+ console.log(title);
668
+ console.log('='.repeat(title.length));
669
+ for (const check of report.checks) {
670
+ const prefix = check.status === 'pass' ? '[PASS]' : check.status === 'warn' ? '[WARN]' : '[FAIL]';
671
+ console.log(`${prefix} ${check.summary}`);
672
+ if (check.detail) {
673
+ console.log(` ${check.detail}`);
674
+ }
675
+ }
676
+ const failures = countDoctorFailures(report.checks);
677
+ const warnings = countDoctorWarnings(report.checks);
678
+ console.log('');
679
+ console.log(`Doctor summary: ${failures} fail, ${warnings} warn`);
680
+ console.log('');
681
+ }
682
+
683
+ function ensureDeviceIdentity({ dryRun }) {
684
+ const { identityPath } = resolveIdentityPaths();
685
+ const existing = loadLocalDeviceIdentity();
686
+ if (existing) {
687
+ return {
688
+ changed: false,
689
+ created: false,
690
+ identityPath,
691
+ deviceId: existing.deviceId,
692
+ warning: '',
693
+ };
694
+ }
695
+
696
+ const keyPair = crypto.generateKeyPairSync('ed25519');
697
+ const nextIdentity = {
698
+ deviceId: crypto.randomUUID(),
699
+ publicKeyPem: keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
700
+ privateKeyPem: keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
701
+ createdAt: new Date().toISOString(),
702
+ };
703
+
704
+ if (!dryRun) {
705
+ writeJsonFile(identityPath, nextIdentity);
706
+ }
707
+ return {
708
+ changed: true,
709
+ created: true,
710
+ identityPath,
711
+ deviceId: nextIdentity.deviceId,
712
+ warning: '',
713
+ };
714
+ }
715
+
716
+ function ensureDeviceAuthToken({ dryRun, gatewayToken, deviceId }) {
717
+ const { deviceAuthPath } = resolveIdentityPaths();
718
+ const snapshot = readJsonFileSafe(deviceAuthPath);
719
+ const existingToken = detectLocalDeviceAuthToken();
720
+ if (existingToken) {
721
+ return {
722
+ changed: false,
723
+ created: false,
724
+ deviceAuthPath,
725
+ warning: '',
726
+ };
727
+ }
728
+
729
+ if (!gatewayToken) {
730
+ return {
731
+ changed: false,
732
+ created: false,
733
+ deviceAuthPath,
734
+ warning: 'Cannot backfill device-auth token because gateway token is missing.',
735
+ };
736
+ }
737
+
738
+ const now = new Date().toISOString();
739
+ const base = snapshot.valid ? deepClonePlain(snapshot.value) : {};
740
+ if (!base.version || typeof base.version !== 'number') {
741
+ base.version = 1;
742
+ }
743
+ if (!base.deviceId) {
744
+ base.deviceId = deviceId || crypto.randomUUID();
745
+ }
746
+ if (!base.tokens || typeof base.tokens !== 'object' || Array.isArray(base.tokens)) {
747
+ base.tokens = {};
748
+ }
749
+ if (!base.tokens.operator || typeof base.tokens.operator !== 'object' || Array.isArray(base.tokens.operator)) {
750
+ base.tokens.operator = {};
751
+ }
752
+ base.tokens.operator.token = gatewayToken;
753
+ if (!Array.isArray(base.tokens.operator.scopes) || base.tokens.operator.scopes.length === 0) {
754
+ base.tokens.operator.scopes = ['operator.admin'];
755
+ }
756
+ base.tokens.operator.updatedAt = now;
757
+
758
+ if (!dryRun) {
759
+ writeJsonFile(deviceAuthPath, base);
760
+ }
761
+ return {
762
+ changed: true,
763
+ created: !snapshot.exists,
764
+ deviceAuthPath,
765
+ warning: '',
766
+ };
767
+ }
768
+
769
+ function normalizeGatewayAuthMode(rawMode) {
770
+ const text = `${rawMode || ''}`.trim().toLowerCase();
771
+ if (text === 'token' || text === 'password') return text;
772
+ return '';
773
+ }
774
+
775
+ function ensureGatewayConfig({ config, options }) {
776
+ const nextConfig = deepClonePlain(config);
777
+ const changes = [];
778
+
779
+ if (!nextConfig.gateway || typeof nextConfig.gateway !== 'object' || Array.isArray(nextConfig.gateway)) {
780
+ nextConfig.gateway = {};
781
+ changes.push('Created gateway section in openclaw.json');
782
+ }
783
+ if (!nextConfig.gateway.mode) {
784
+ nextConfig.gateway.mode = 'local';
785
+ changes.push('Set gateway.mode=local');
786
+ }
787
+ if (!nextConfig.gateway.auth || typeof nextConfig.gateway.auth !== 'object' || Array.isArray(nextConfig.gateway.auth)) {
788
+ nextConfig.gateway.auth = {};
789
+ changes.push('Created gateway.auth section');
790
+ }
791
+
792
+ let authMode = normalizeGatewayAuthMode(nextConfig.gateway.auth.mode);
793
+ const tokenValue = `${nextConfig.gateway.auth.token || ''}`.trim();
794
+ const passwordValue = `${nextConfig.gateway.auth.password || ''}`.trim();
795
+ if (!authMode) {
796
+ authMode = tokenValue ? 'token' : (passwordValue ? 'password' : 'token');
797
+ nextConfig.gateway.auth.mode = authMode;
798
+ changes.push(`Set gateway.auth.mode=${authMode}`);
799
+ }
800
+
801
+ let generatedToken = '';
802
+ if (authMode === 'token') {
803
+ if (!tokenValue) {
804
+ generatedToken = `${options['upstream-token'] || crypto.randomBytes(16).toString('hex')}`;
805
+ nextConfig.gateway.auth.token = generatedToken;
806
+ changes.push('Generated and set gateway.auth.token');
807
+ }
808
+ } else if (!passwordValue) {
809
+ const generatedPassword = crypto.randomBytes(16).toString('hex');
810
+ nextConfig.gateway.auth.password = generatedPassword;
811
+ changes.push('Generated and set gateway.auth.password');
812
+ }
813
+
814
+ const effectiveGatewayToken =
815
+ `${options['upstream-token'] || ''}`.trim() ||
816
+ detectLocalDeviceAuthToken() ||
817
+ extractGatewayTokenFromConfig(nextConfig) ||
818
+ `${process.env.OPENCLAW_UPSTREAM_TOKEN || ''}`.trim() ||
819
+ generatedToken;
820
+
821
+ return {
822
+ nextConfig,
823
+ changes,
824
+ effectiveGatewayToken: `${effectiveGatewayToken || ''}`.trim(),
825
+ };
826
+ }
827
+
828
+ async function applyInstallFixes(options, dryRun) {
829
+ const configPath = resolveOpenClawConfigPath(options);
830
+ const configSnapshot = readJsonFileSafe(configPath);
831
+ const startingConfig = configSnapshot.valid ? configSnapshot.value : {};
832
+ const normalized = ensureGatewayConfig({
833
+ config: startingConfig,
834
+ options,
835
+ });
836
+
837
+ let backupPath = '';
838
+ if (normalized.changes.length > 0) {
839
+ backupPath = backupFile(configPath, dryRun);
840
+ if (!dryRun) {
841
+ writeJsonFile(configPath, normalized.nextConfig);
842
+ }
843
+ }
844
+
845
+ const identityResult = ensureDeviceIdentity({ dryRun });
846
+ const deviceAuthResult = ensureDeviceAuthToken({
847
+ dryRun,
848
+ gatewayToken: normalized.effectiveGatewayToken,
849
+ deviceId: identityResult.deviceId,
850
+ });
851
+
852
+ const changes = [...normalized.changes];
853
+ if (identityResult.changed) {
854
+ changes.push(`Created device identity: ${identityResult.identityPath}`);
855
+ }
856
+ if (deviceAuthResult.changed) {
857
+ changes.push(`Synced device-auth token: ${deviceAuthResult.deviceAuthPath}`);
858
+ }
859
+
860
+ const warnings = [];
861
+ if (!configSnapshot.valid && configSnapshot.exists) {
862
+ warnings.push(`Existing config was invalid JSON and has been replaced: ${configPath}`);
863
+ }
864
+ if (deviceAuthResult.warning) {
865
+ warnings.push(deviceAuthResult.warning);
866
+ }
867
+
868
+ return {
869
+ configPath,
870
+ backupPath,
871
+ changes,
872
+ warnings,
873
+ effectiveGatewayToken: normalized.effectiveGatewayToken,
874
+ };
875
+ }
876
+
877
+ function printInstallFixSummary(result, dryRun) {
878
+ console.log('');
879
+ console.log(dryRun ? 'Install Fix Plan (dry-run)' : 'Install Fixes');
880
+ console.log(dryRun ? '==========================' : '=============');
881
+ if (result.changes.length === 0) {
882
+ console.log('[PASS] No config repair needed.');
883
+ } else {
884
+ for (const item of result.changes) {
885
+ console.log(`[PASS] ${item}`);
886
+ }
887
+ }
888
+ if (result.backupPath) {
889
+ console.log(`[PASS] ${dryRun ? 'Would backup' : 'Backup'} openclaw.json -> ${result.backupPath}`);
890
+ }
891
+ for (const warning of result.warnings) {
892
+ console.log(`[WARN] ${warning}`);
893
+ }
894
+ console.log('');
895
+ }
896
+
897
+ async function runDoctor(options) {
898
+ const report = await collectDoctorReport(options);
899
+ printDoctorReport(report);
900
+ const failures = countDoctorFailures(report.checks);
901
+ if (failures > 0) {
902
+ process.exitCode = 1;
903
+ }
904
+ }
905
+
906
+ async function runInstall(options) {
907
+ const dryRun = optionEnabled(options, 'dry-run');
908
+ const skipGatewayRestart = optionEnabled(options, 'skip-gateway-restart');
909
+ const skipConnector = optionEnabled(options, 'skip-connector');
910
+ const openclawBin = resolveOpenClawBin(options);
911
+
912
+ console.log('');
913
+ console.log('MateClaw Local OpenClaw Installer');
914
+ console.log('=================================');
915
+
916
+ const before = await collectDoctorReport(options);
917
+ printDoctorReport(before, 'Preflight Doctor');
918
+
919
+ const fixResult = await applyInstallFixes(options, dryRun);
920
+ printInstallFixSummary(fixResult, dryRun);
921
+
922
+ const installOptions = { ...options };
923
+
924
+ const openclawUrl = stripTrailingSlash(
925
+ installOptions['openclaw-url'] ||
926
+ process.env.OPENCLAW_BASE_URL ||
927
+ DEFAULT_OPENCLAW_URL,
928
+ );
929
+
930
+ if (!skipGatewayRestart && !dryRun) {
931
+ const startResult = await startGatewayIfNeeded(openclawBin, openclawUrl);
932
+ if (startResult.attempted && startResult.ok) {
933
+ console.log('[PASS] OpenClaw gateway start triggered and healthy.');
934
+ } else if (startResult.attempted && !startResult.ok) {
935
+ console.log(`[WARN] OpenClaw gateway start failed: ${startResult.detail}`);
936
+ }
937
+
938
+ const restartResult = runOpenClawCommand(openclawBin, ['gateway', 'restart'], 30000);
939
+ if (restartResult.ok) {
940
+ console.log('[PASS] OpenClaw gateway restart triggered.');
941
+ } else {
942
+ console.log(`[WARN] OpenClaw gateway restart failed: ${summarizeCommandFailure(restartResult)}`);
943
+ }
944
+ const recovered = await waitForGatewayHealthy(openclawUrl, 20000);
945
+ if (recovered.ok) {
946
+ console.log('[PASS] Gateway health recovered after restart.');
947
+ } else {
948
+ console.log(`[WARN] Gateway health still unavailable: ${recovered.detail}`);
949
+ }
950
+ } else if (skipGatewayRestart) {
951
+ console.log('[WARN] Skipped gateway restart (--skip-gateway-restart).');
952
+ }
953
+
954
+ const connectorHost =
955
+ installOptions.host ||
956
+ process.env.MATECLAW_CONNECTOR_HOST ||
957
+ DEFAULT_LISTEN_HOST;
958
+ const preferredPort = parsePortOrDefault(
959
+ installOptions.port || process.env.MATECLAW_CONNECTOR_PORT || `${DEFAULT_PORT}`,
960
+ DEFAULT_PORT,
961
+ );
962
+ const portPlan = await pickAvailablePort(preferredPort, connectorHost, 20);
963
+ if (!portPlan.found) {
964
+ throw new Error(`No free connector port found near ${preferredPort}.`);
965
+ }
966
+ if (portPlan.selectedPort !== preferredPort) {
967
+ installOptions.port = `${portPlan.selectedPort}`;
968
+ console.log(`[WARN] Port ${preferredPort} is busy, switched to ${portPlan.selectedPort}.`);
969
+ }
970
+
971
+ if (!installOptions['lan-host']) {
972
+ const lanHost = detectLanAddress();
973
+ if (lanHost) {
974
+ installOptions['lan-host'] = lanHost;
975
+ console.log(`[PASS] Selected LAN host: ${lanHost}`);
976
+ }
977
+ }
978
+
979
+ const after = await collectDoctorReport(installOptions);
980
+ printDoctorReport(after, 'Post-fix Doctor');
981
+
982
+ const postFixFailures = countDoctorFailures(after.checks);
983
+ if (postFixFailures > 0) {
984
+ if (dryRun) {
985
+ console.log('[WARN] Dry-run detected blocking failures after auto-fix. Fix them before real install.');
986
+ return;
987
+ }
988
+ throw new Error('Doctor still reports blocking failures after auto-fix.');
989
+ }
990
+
991
+ if (dryRun) {
992
+ console.log('[PASS] Dry-run completed. Re-run without --dry-run to start connector.');
993
+ return;
994
+ }
995
+
996
+ if (skipConnector) {
997
+ console.log('[PASS] Install completed without starting connector (--skip-connector).');
998
+ return;
999
+ }
1000
+
1001
+ const config = buildConfig(installOptions);
1002
+ await startConnector(config);
1003
+ }
1004
+
1005
+ function buildConfig(options) {
1006
+ const openclawUrl = stripTrailingSlash(
1007
+ options['openclaw-url'] ||
1008
+ process.env.OPENCLAW_BASE_URL ||
1009
+ DEFAULT_OPENCLAW_URL,
1010
+ );
1011
+ const chatMode = normalizeChatMode(
1012
+ options['chat-mode'] ||
1013
+ process.env.MATECLAW_CHAT_MODE ||
1014
+ DEFAULT_CHAT_MODE,
1015
+ );
1016
+ const chatPath = normalizePath(
1017
+ options['chat-path'] ||
1018
+ process.env.OPENCLAW_CHAT_PATH ||
1019
+ DEFAULT_CHAT_PATH,
1020
+ );
1021
+ const acceptedChatPaths = dedupePaths([chatPath, ...CHAT_PATH_FALLBACKS]);
1022
+ const listenHost =
1023
+ options.host || process.env.MATECLAW_CONNECTOR_HOST || DEFAULT_LISTEN_HOST;
1024
+ const port = Number.parseInt(
1025
+ options.port || process.env.MATECLAW_CONNECTOR_PORT || `${DEFAULT_PORT}`,
1026
+ 10,
1027
+ );
1028
+ const lanCandidates = listLanCandidates();
1029
+ const lanHost =
1030
+ options['lan-host'] ||
1031
+ process.env.MATECLAW_CONNECTOR_LAN_HOST ||
1032
+ lanCandidates[0]?.address ||
1033
+ '';
1034
+ const agentId =
1035
+ options['agent-id'] || process.env.MATECLAW_AGENT_ID || DEFAULT_AGENT_ID;
1036
+ const sessionKey = normalizeSessionKey(
1037
+ options['session-key'] ||
1038
+ process.env.MATECLAW_SESSION_KEY ||
1039
+ `agent:${normalizeSessionSegment(agentId)}:main`,
1040
+ );
1041
+ const name =
1042
+ options.name || process.env.MATECLAW_CONNECTOR_NAME || `${os.hostname()} OpenClaw`;
1043
+ const token =
1044
+ options.token || process.env.MATECLAW_CONNECTOR_TOKEN || crypto.randomBytes(16).toString('hex');
1045
+ const expiresMinutes = Number.parseInt(
1046
+ options['expires-minutes'] ||
1047
+ process.env.MATECLAW_CONNECTOR_EXPIRES_MINUTES ||
1048
+ `${DEFAULT_EXPIRES_MINUTES}`,
1049
+ 10,
1050
+ );
1051
+ const upstreamTimeoutMs = Number.parseInt(
1052
+ options['upstream-timeout-ms'] ||
1053
+ process.env.OPENCLAW_UPSTREAM_TIMEOUT_MS ||
1054
+ `${DEFAULT_UPSTREAM_TIMEOUT_MS}`,
1055
+ 10,
1056
+ );
1057
+ const upstreamToken =
1058
+ options['upstream-token'] ||
1059
+ process.env.OPENCLAW_UPSTREAM_TOKEN ||
1060
+ detectLocalDeviceAuthToken() ||
1061
+ detectLocalGatewayToken();
1062
+
1063
+ if (!Number.isFinite(port) || port <= 0) {
1064
+ throw new Error('Invalid port.');
1065
+ }
1066
+ if (!Number.isFinite(expiresMinutes) || expiresMinutes <= 0) {
1067
+ throw new Error('Invalid expires-minutes.');
1068
+ }
1069
+ if (!Number.isFinite(upstreamTimeoutMs) || upstreamTimeoutMs <= 0) {
1070
+ throw new Error('Invalid upstream-timeout-ms.');
1071
+ }
1072
+ if (!lanHost) {
1073
+ throw new Error(
1074
+ 'Unable to detect a LAN host automatically. Pass --lan-host or set MATECLAW_CONNECTOR_LAN_HOST.',
1075
+ );
1076
+ }
1077
+
1078
+ const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
1079
+ const baseUrl = `http://${lanHost}:${port}`;
1080
+
1081
+ return {
1082
+ openclawUrl,
1083
+ chatMode,
1084
+ chatPath,
1085
+ listenHost,
1086
+ port,
1087
+ lanHost,
1088
+ agentId,
1089
+ sessionKey,
1090
+ name,
1091
+ token,
1092
+ expiresAt,
1093
+ upstreamTimeoutMs,
1094
+ upstreamToken,
1095
+ lanCandidates,
1096
+ baseUrl,
1097
+ acceptedChatPaths,
1098
+ qrPayload: {
1099
+ type: PAYLOAD_TYPE,
1100
+ name,
1101
+ baseUrl,
1102
+ chatPath,
1103
+ token,
1104
+ agentId,
1105
+ sessionId: sessionKey,
1106
+ expiresAt: expiresAt.toISOString(),
1107
+ },
1108
+ };
1109
+ }
1110
+
1111
+ function detectLocalGatewayToken() {
1112
+ try {
1113
+ const configPath = process.env.OPENCLAW_CONFIG_PATH ||
1114
+ path.join(os.homedir(), '.openclaw', 'openclaw.json');
1115
+ if (!fs.existsSync(configPath)) return '';
1116
+
1117
+ const raw = fs.readFileSync(configPath, 'utf8');
1118
+ const parsed = JSON.parse(raw);
1119
+ const mode = `${parsed?.gateway?.auth?.mode || ''}`.trim().toLowerCase();
1120
+ if (mode === 'token') {
1121
+ return `${parsed?.gateway?.auth?.token || ''}`.trim();
1122
+ }
1123
+ if (mode === 'password') {
1124
+ return `${parsed?.gateway?.auth?.password || ''}`.trim();
1125
+ }
1126
+ return '';
1127
+ } catch {
1128
+ return '';
1129
+ }
1130
+ }
1131
+
1132
+ function detectLocalDeviceAuthToken() {
1133
+ try {
1134
+ const filePath = path.join(os.homedir(), '.openclaw', 'identity', 'device-auth.json');
1135
+ if (!fs.existsSync(filePath)) return '';
1136
+ const raw = fs.readFileSync(filePath, 'utf8');
1137
+ const parsed = JSON.parse(raw);
1138
+ const token = `${parsed?.tokens?.operator?.token || ''}`.trim();
1139
+ return token;
1140
+ } catch {
1141
+ return '';
1142
+ }
1143
+ }
1144
+
1145
+ function loadLocalDeviceIdentity() {
1146
+ try {
1147
+ const filePath = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
1148
+ if (!fs.existsSync(filePath)) return null;
1149
+ const raw = fs.readFileSync(filePath, 'utf8');
1150
+ const parsed = JSON.parse(raw);
1151
+ const deviceId = `${parsed?.deviceId || ''}`.trim();
1152
+ const publicKeyPem = `${parsed?.publicKeyPem || ''}`.trim();
1153
+ const privateKeyPem = `${parsed?.privateKeyPem || ''}`.trim();
1154
+ if (!deviceId || !publicKeyPem || !privateKeyPem) return null;
1155
+ return {
1156
+ deviceId,
1157
+ publicKeyPem,
1158
+ privateKeyPem,
1159
+ };
1160
+ } catch {
1161
+ return null;
1162
+ }
1163
+ }
1164
+
1165
+ async function startConnector(config) {
1166
+ const server = http.createServer((req, res) => {
1167
+ handleRequest(req, res, config).catch((error) => {
1168
+ sendJson(res, error.statusCode || 500, {
1169
+ error: 'connector_error',
1170
+ message: error.message || 'Unexpected connector error.',
1171
+ });
1172
+ });
1173
+ });
1174
+
1175
+ await new Promise((resolve, reject) => {
1176
+ server.once('error', reject);
1177
+ server.listen(config.port, config.listenHost, resolve);
1178
+ });
1179
+
1180
+ printBanner(config);
1181
+
1182
+ const shutdown = () => {
1183
+ console.log('\nShutting down Local OpenClaw Demo Connector...');
1184
+ server.close(() => process.exit(0));
1185
+ };
1186
+
1187
+ process.on('SIGINT', shutdown);
1188
+ process.on('SIGTERM', shutdown);
1189
+ }
1190
+
1191
+ async function handleRequest(req, res, config) {
1192
+ const requestUrl = new URL(req.url || '/', config.baseUrl);
1193
+
1194
+ addCorsHeaders(res);
1195
+ if (req.method === 'OPTIONS') {
1196
+ res.writeHead(204);
1197
+ res.end();
1198
+ return;
1199
+ }
1200
+
1201
+ if (requestUrl.pathname === '/health' && req.method === 'GET') {
1202
+ sendJson(res, 200, {
1203
+ ok: true,
1204
+ mode: 'mateclaw_local_openclaw_demo',
1205
+ chatMode: config.chatMode,
1206
+ sessionId: config.sessionKey,
1207
+ upstream: config.openclawUrl,
1208
+ chatPath: config.chatPath,
1209
+ acceptedChatPaths: config.acceptedChatPaths,
1210
+ expiresAt: config.expiresAt.toISOString(),
1211
+ });
1212
+ return;
1213
+ }
1214
+
1215
+ if (requestUrl.pathname === '/binding' && req.method === 'GET') {
1216
+ sendJson(res, 200, config.qrPayload);
1217
+ return;
1218
+ }
1219
+
1220
+ if (isChatPathRequest(requestUrl.pathname, req.method, config.acceptedChatPaths)) {
1221
+ requireBearer(req, config.token);
1222
+ const body = await readBody(req);
1223
+ if (config.chatMode === 'gateway-session') {
1224
+ const payload = parseChatPayload(body);
1225
+ const userMessage = extractLatestUserMessage(payload);
1226
+ const sessionId = config.sessionKey;
1227
+ const gatewayResult = await chatViaGatewaySession({
1228
+ openclawUrl: config.openclawUrl,
1229
+ authToken: config.upstreamToken,
1230
+ sessionKey: sessionId,
1231
+ message: userMessage,
1232
+ timeoutMs: config.upstreamTimeoutMs,
1233
+ });
1234
+ sendJson(res, 200, {
1235
+ id: gatewayResult.runId || crypto.randomUUID(),
1236
+ object: 'chat.completion',
1237
+ created: Math.floor(Date.now() / 1000),
1238
+ model: gatewayResult.model || 'openclaw/gateway-session',
1239
+ provider: gatewayResult.provider || 'openclaw',
1240
+ traceId: gatewayResult.runId || '',
1241
+ sessionId,
1242
+ chatMode: 'gateway-session',
1243
+ reply: {
1244
+ role: 'assistant',
1245
+ text: gatewayResult.replyText,
1246
+ },
1247
+ choices: [
1248
+ {
1249
+ index: 0,
1250
+ message: {
1251
+ role: 'assistant',
1252
+ content: gatewayResult.replyText,
1253
+ },
1254
+ finish_reason: 'stop',
1255
+ },
1256
+ ],
1257
+ });
1258
+ return;
1259
+ }
1260
+
1261
+ const headers = {
1262
+ 'content-type': req.headers['content-type'] || 'application/json',
1263
+ };
1264
+
1265
+ if (config.upstreamToken) {
1266
+ headers.authorization = `Bearer ${config.upstreamToken}`;
1267
+ }
1268
+
1269
+ const upstream = await forwardChatWithFallback({
1270
+ openclawUrl: config.openclawUrl,
1271
+ requestedPath: normalizePath(requestUrl.pathname),
1272
+ preferredPath: config.chatPath,
1273
+ candidatePaths: config.acceptedChatPaths,
1274
+ upstreamTimeoutMs: config.upstreamTimeoutMs,
1275
+ headers,
1276
+ body,
1277
+ });
1278
+
1279
+ res.writeHead(upstream.statusCode, {
1280
+ ...upstream.headers,
1281
+ 'access-control-allow-origin': '*',
1282
+ 'x-openclaw-chat-path': upstream.chatPath,
1283
+ });
1284
+ res.end(upstream.body);
1285
+ return;
1286
+ }
1287
+
1288
+ sendJson(res, 404, {
1289
+ error: 'not_found',
1290
+ message: `Unsupported route: ${req.method || 'GET'} ${requestUrl.pathname}`,
1291
+ });
1292
+ }
1293
+
1294
+ function requireBearer(req, expectedToken) {
1295
+ const header = req.headers.authorization || '';
1296
+ const prefix = 'Bearer ';
1297
+ if (!header.startsWith(prefix) || header.slice(prefix.length).trim() !== expectedToken) {
1298
+ const error = new Error('Unauthorized');
1299
+ error.statusCode = 401;
1300
+ throw error;
1301
+ }
1302
+ }
1303
+
1304
+ function addCorsHeaders(res) {
1305
+ res.setHeader('Access-Control-Allow-Origin', '*');
1306
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
1307
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
1308
+ }
1309
+
1310
+ function sendJson(res, statusCode, payload) {
1311
+ res.writeHead(statusCode, {
1312
+ 'content-type': 'application/json; charset=utf-8',
1313
+ 'access-control-allow-origin': '*',
1314
+ });
1315
+ res.end(`${JSON.stringify(payload, null, 2)}\n`);
1316
+ }
1317
+
1318
+ async function readBody(req) {
1319
+ const chunks = [];
1320
+ for await (const chunk of req) {
1321
+ chunks.push(chunk);
1322
+ }
1323
+ return Buffer.concat(chunks);
1324
+ }
1325
+
1326
+ function parseChatPayload(body) {
1327
+ try {
1328
+ const text = body.toString('utf8').trim();
1329
+ if (!text) {
1330
+ throw createConnectorError(400, 'Empty chat payload.');
1331
+ }
1332
+ const parsed = JSON.parse(text);
1333
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1334
+ throw createConnectorError(400, 'Invalid chat payload object.');
1335
+ }
1336
+ return parsed;
1337
+ } catch (error) {
1338
+ if (error?.statusCode) {
1339
+ throw error;
1340
+ }
1341
+ throw createConnectorError(400, 'Chat payload is not valid JSON.');
1342
+ }
1343
+ }
1344
+
1345
+ function extractLatestUserMessage(payload) {
1346
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
1347
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1348
+ const item = messages[i];
1349
+ if (!item || typeof item !== 'object') continue;
1350
+ const role = `${item.role || ''}`.trim().toLowerCase();
1351
+ if (role !== 'user') continue;
1352
+ const text = toText(item.content).trim();
1353
+ if (text) return text;
1354
+ }
1355
+
1356
+ const fallbackText = toText(payload.input ?? payload.message ?? payload.prompt).trim();
1357
+ if (fallbackText) return fallbackText;
1358
+ throw createConnectorError(400, 'No user message found in request payload.');
1359
+ }
1360
+
1361
+ async function chatViaGatewaySession({
1362
+ openclawUrl,
1363
+ authToken,
1364
+ sessionKey,
1365
+ message,
1366
+ timeoutMs,
1367
+ }) {
1368
+ if (!authToken || !authToken.trim()) {
1369
+ throw createConnectorError(
1370
+ 401,
1371
+ 'Missing upstream gateway auth token. Set OPENCLAW_UPSTREAM_TOKEN or configure gateway.auth.token.',
1372
+ );
1373
+ }
1374
+ const wsUrl = toWebSocketUrl(openclawUrl);
1375
+ const client = await GatewaySessionClient.connect({
1376
+ wsUrl,
1377
+ authToken: authToken.trim(),
1378
+ timeoutMs,
1379
+ });
1380
+
1381
+ try {
1382
+ const sendResult = await client.request(
1383
+ 'chat.send',
1384
+ {
1385
+ sessionKey,
1386
+ message,
1387
+ idempotencyKey: crypto.randomUUID(),
1388
+ },
1389
+ timeoutMs,
1390
+ );
1391
+ const runId = `${sendResult?.runId || ''}`.trim();
1392
+
1393
+ let finalEvent = null;
1394
+ try {
1395
+ finalEvent = await client.waitForEvent(
1396
+ (evt) =>
1397
+ evt?.event === 'chat' &&
1398
+ evt?.payload?.sessionKey === sessionKey &&
1399
+ (runId ? evt?.payload?.runId === runId : true) &&
1400
+ (evt?.payload?.state === 'final' || evt?.payload?.state === 'error'),
1401
+ timeoutMs,
1402
+ );
1403
+ } catch {
1404
+ finalEvent = null;
1405
+ }
1406
+
1407
+ if (finalEvent?.payload?.state === 'error') {
1408
+ const errorMessage = `${finalEvent.payload.errorMessage || 'Gateway chat failed.'}`.trim();
1409
+ throw createConnectorError(502, errorMessage || 'Gateway chat failed.');
1410
+ }
1411
+
1412
+ let replyText = extractTextFromChatPayload(finalEvent?.payload?.message).trim();
1413
+ if (!replyText) {
1414
+ const history = await client.request(
1415
+ 'chat.history',
1416
+ {
1417
+ sessionKey,
1418
+ limit: 30,
1419
+ },
1420
+ Math.min(timeoutMs, 30000),
1421
+ );
1422
+ replyText = extractLatestAssistantTextFromHistory(history?.messages).trim();
1423
+ }
1424
+
1425
+ if (!replyText) {
1426
+ throw createConnectorError(
1427
+ 502,
1428
+ 'Gateway chat completed but no assistant content was returned.',
1429
+ );
1430
+ }
1431
+
1432
+ return {
1433
+ runId,
1434
+ replyText,
1435
+ provider: 'openclaw',
1436
+ model: 'openclaw/gateway-session',
1437
+ };
1438
+ } catch (error) {
1439
+ if (error?.statusCode) {
1440
+ throw error;
1441
+ }
1442
+ const text = `${error?.message || error || ''}`;
1443
+ if (text.toLowerCase().includes('timeout')) {
1444
+ throw createConnectorError(504, `OpenClaw gateway timeout after ${timeoutMs}ms`);
1445
+ }
1446
+ if (text.toLowerCase().includes('unauthorized')) {
1447
+ throw createConnectorError(401, text);
1448
+ }
1449
+ throw createConnectorError(502, `Gateway session error: ${text}`);
1450
+ } finally {
1451
+ client.close();
1452
+ }
1453
+ }
1454
+
1455
+ class GatewaySessionClient {
1456
+ constructor(ws, timeoutMs) {
1457
+ this.ws = ws;
1458
+ this.timeoutMs = timeoutMs;
1459
+ this.pendingById = new Map();
1460
+ this.eventWaiters = new Set();
1461
+ this.closed = false;
1462
+
1463
+ this.ws.on('message', (data) => {
1464
+ this._handleFrame(data);
1465
+ });
1466
+ this.ws.on('error', () => {
1467
+ // Error details are surfaced through pending request rejections.
1468
+ });
1469
+ this.ws.on('close', () => {
1470
+ this.closed = true;
1471
+ for (const pending of this.pendingById.values()) {
1472
+ pending.reject(new Error('gateway websocket closed'));
1473
+ }
1474
+ this.pendingById.clear();
1475
+ for (const waiter of this.eventWaiters.values()) {
1476
+ waiter.reject(new Error('gateway websocket closed'));
1477
+ }
1478
+ this.eventWaiters.clear();
1479
+ });
1480
+ }
1481
+
1482
+ static async connect({ wsUrl, authToken, timeoutMs }) {
1483
+ const ws = await openWebSocket(wsUrl, timeoutMs);
1484
+ const client = new GatewaySessionClient(ws, timeoutMs);
1485
+ const challengeEvent = await client.waitForEvent(
1486
+ (evt) => evt?.event === 'connect.challenge',
1487
+ timeoutMs,
1488
+ );
1489
+ const nonce = `${challengeEvent?.payload?.nonce || ''}`.trim();
1490
+ if (!nonce) {
1491
+ throw new Error('gateway connect challenge missing nonce');
1492
+ }
1493
+
1494
+ const role = 'operator';
1495
+ const scopes = ['operator.admin'];
1496
+ const clientId = 'gateway-client';
1497
+ const clientMode = 'backend';
1498
+ const clientDisplayName = 'MateClaw Local Connector';
1499
+ const platform = process.platform;
1500
+ const deviceIdentity = loadLocalDeviceIdentity();
1501
+ const signedAtMs = Date.now();
1502
+ const device = deviceIdentity ? {
1503
+ id: deviceIdentity.deviceId,
1504
+ publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
1505
+ signature: signDevicePayload(
1506
+ deviceIdentity.privateKeyPem,
1507
+ buildDeviceAuthPayloadV3({
1508
+ deviceId: deviceIdentity.deviceId,
1509
+ clientId,
1510
+ clientMode,
1511
+ role,
1512
+ scopes,
1513
+ signedAtMs,
1514
+ token: authToken,
1515
+ nonce,
1516
+ platform,
1517
+ deviceFamily: '',
1518
+ }),
1519
+ ),
1520
+ signedAt: signedAtMs,
1521
+ nonce,
1522
+ } : undefined;
1523
+
1524
+ const connectPayload = {
1525
+ minProtocol: 3,
1526
+ maxProtocol: 3,
1527
+ role,
1528
+ scopes,
1529
+ auth: {
1530
+ token: authToken,
1531
+ },
1532
+ client: {
1533
+ id: clientId,
1534
+ displayName: clientDisplayName,
1535
+ version: '1.0.0',
1536
+ mode: clientMode,
1537
+ platform,
1538
+ },
1539
+ ...(device ? { device } : {}),
1540
+ };
1541
+
1542
+ await client.request('connect', connectPayload, timeoutMs);
1543
+ return client;
1544
+ }
1545
+
1546
+ close() {
1547
+ if (this.closed) return;
1548
+ this.closed = true;
1549
+ try {
1550
+ this.ws.close();
1551
+ } catch {
1552
+ // no-op
1553
+ }
1554
+ }
1555
+
1556
+ async request(method, params, timeoutMs = this.timeoutMs) {
1557
+ const id = crypto.randomUUID();
1558
+ const frame = {
1559
+ type: 'req',
1560
+ id,
1561
+ method,
1562
+ params,
1563
+ };
1564
+
1565
+ return await new Promise((resolve, reject) => {
1566
+ if (this.closed) {
1567
+ reject(new Error('gateway websocket already closed'));
1568
+ return;
1569
+ }
1570
+
1571
+ const timer = setTimeout(() => {
1572
+ this.pendingById.delete(id);
1573
+ reject(new Error(`gateway request timeout: ${method}`));
1574
+ }, timeoutMs);
1575
+
1576
+ this.pendingById.set(id, {
1577
+ resolve: (payload) => {
1578
+ clearTimeout(timer);
1579
+ resolve(payload);
1580
+ },
1581
+ reject: (error) => {
1582
+ clearTimeout(timer);
1583
+ reject(error);
1584
+ },
1585
+ });
1586
+
1587
+ try {
1588
+ this.ws.send(JSON.stringify(frame));
1589
+ } catch (error) {
1590
+ clearTimeout(timer);
1591
+ this.pendingById.delete(id);
1592
+ reject(error);
1593
+ }
1594
+ });
1595
+ }
1596
+
1597
+ async waitForEvent(predicate, timeoutMs = this.timeoutMs) {
1598
+ return await new Promise((resolve, reject) => {
1599
+ if (this.closed) {
1600
+ reject(new Error('gateway websocket already closed'));
1601
+ return;
1602
+ }
1603
+ const waiter = {
1604
+ predicate,
1605
+ resolve: (event) => {
1606
+ clearTimeout(timer);
1607
+ this.eventWaiters.delete(waiter);
1608
+ resolve(event);
1609
+ },
1610
+ reject: (error) => {
1611
+ clearTimeout(timer);
1612
+ this.eventWaiters.delete(waiter);
1613
+ reject(error);
1614
+ },
1615
+ };
1616
+ const timer = setTimeout(() => {
1617
+ this.eventWaiters.delete(waiter);
1618
+ reject(new Error('gateway event timeout'));
1619
+ }, timeoutMs);
1620
+ this.eventWaiters.add(waiter);
1621
+ });
1622
+ }
1623
+
1624
+ _handleFrame(data) {
1625
+ let frame = null;
1626
+ try {
1627
+ const text = Buffer.isBuffer(data) ? data.toString('utf8') : `${data}`;
1628
+ frame = JSON.parse(text);
1629
+ } catch {
1630
+ return;
1631
+ }
1632
+ if (!frame || typeof frame !== 'object') return;
1633
+
1634
+ if (frame.type === 'res') {
1635
+ const id = `${frame.id || ''}`;
1636
+ const pending = this.pendingById.get(id);
1637
+ if (!pending) return;
1638
+ this.pendingById.delete(id);
1639
+ if (frame.ok) {
1640
+ pending.resolve(frame.payload);
1641
+ } else {
1642
+ const detail = `${frame?.error?.message || 'gateway request failed'}`.trim();
1643
+ pending.reject(new Error(detail || 'gateway request failed'));
1644
+ }
1645
+ return;
1646
+ }
1647
+
1648
+ if (frame.type === 'event') {
1649
+ for (const waiter of Array.from(this.eventWaiters)) {
1650
+ let matched = false;
1651
+ try {
1652
+ matched = waiter.predicate(frame);
1653
+ } catch {
1654
+ matched = false;
1655
+ }
1656
+ if (matched) {
1657
+ waiter.resolve(frame);
1658
+ }
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ async function openWebSocket(wsUrl, timeoutMs) {
1665
+ return await new Promise((resolve, reject) => {
1666
+ const ws = new WebSocket(wsUrl);
1667
+ const timer = setTimeout(() => {
1668
+ try {
1669
+ ws.close();
1670
+ } catch {
1671
+ // no-op
1672
+ }
1673
+ reject(new Error(`gateway websocket connect timeout: ${wsUrl}`));
1674
+ }, Math.min(timeoutMs, 15000));
1675
+
1676
+ ws.once('open', () => {
1677
+ clearTimeout(timer);
1678
+ resolve(ws);
1679
+ });
1680
+ ws.once('error', (error) => {
1681
+ clearTimeout(timer);
1682
+ reject(error);
1683
+ });
1684
+ });
1685
+ }
1686
+
1687
+ function toWebSocketUrl(baseUrl) {
1688
+ const url = new URL(`${baseUrl}/`);
1689
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1690
+ if (!url.pathname || url.pathname === '') {
1691
+ url.pathname = '/';
1692
+ }
1693
+ return url.toString();
1694
+ }
1695
+
1696
+ function buildDeviceAuthPayloadV3({
1697
+ deviceId,
1698
+ clientId,
1699
+ clientMode,
1700
+ role,
1701
+ scopes,
1702
+ signedAtMs,
1703
+ token,
1704
+ nonce,
1705
+ platform,
1706
+ deviceFamily,
1707
+ }) {
1708
+ const scopeText = Array.isArray(scopes) ? scopes.join(',') : '';
1709
+ return [
1710
+ 'v3',
1711
+ `${deviceId || ''}`.trim(),
1712
+ `${clientId || ''}`.trim(),
1713
+ `${clientMode || ''}`.trim(),
1714
+ `${role || ''}`.trim(),
1715
+ scopeText,
1716
+ `${signedAtMs}`,
1717
+ `${token || ''}`.trim(),
1718
+ `${nonce || ''}`.trim(),
1719
+ normalizeDeviceMetadataForAuth(platform),
1720
+ normalizeDeviceMetadataForAuth(deviceFamily),
1721
+ ].join('|');
1722
+ }
1723
+
1724
+ function normalizeDeviceMetadataForAuth(value) {
1725
+ if (typeof value !== 'string') return '';
1726
+ const trimmed = value.trim();
1727
+ if (!trimmed) return '';
1728
+ return trimmed.replace(/[A-Z]/g, (char) =>
1729
+ String.fromCharCode(char.charCodeAt(0) + 32),
1730
+ );
1731
+ }
1732
+
1733
+ function signDevicePayload(privateKeyPem, payload) {
1734
+ const key = crypto.createPrivateKey(privateKeyPem);
1735
+ const signature = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
1736
+ return base64UrlEncode(signature);
1737
+ }
1738
+
1739
+ function publicKeyRawBase64UrlFromPem(publicKeyPem) {
1740
+ const spki = crypto.createPublicKey(publicKeyPem).export({
1741
+ type: 'spki',
1742
+ format: 'der',
1743
+ });
1744
+ const ed25519SpkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
1745
+ const raw =
1746
+ spki.length === ed25519SpkiPrefix.length + 32 &&
1747
+ spki.subarray(0, ed25519SpkiPrefix.length).equals(ed25519SpkiPrefix)
1748
+ ? spki.subarray(ed25519SpkiPrefix.length)
1749
+ : spki;
1750
+ return base64UrlEncode(raw);
1751
+ }
1752
+
1753
+ function base64UrlEncode(buf) {
1754
+ return buf
1755
+ .toString('base64')
1756
+ .replaceAll('+', '-')
1757
+ .replaceAll('/', '_')
1758
+ .replace(/=+$/g, '');
1759
+ }
1760
+
1761
+ function extractTextFromChatPayload(message) {
1762
+ if (!message || typeof message !== 'object') return '';
1763
+ return toText(message.content ?? message.text ?? message.message ?? '').trim();
1764
+ }
1765
+
1766
+ function extractLatestAssistantTextFromHistory(messages) {
1767
+ const list = Array.isArray(messages) ? messages : [];
1768
+ for (let i = list.length - 1; i >= 0; i -= 1) {
1769
+ const item = list[i];
1770
+ if (!item || typeof item !== 'object') continue;
1771
+ const role = `${item.role || ''}`.trim().toLowerCase();
1772
+ if (role !== 'assistant') continue;
1773
+ const text = toText(item.content).trim();
1774
+ if (text) return text;
1775
+ }
1776
+ return '';
1777
+ }
1778
+
1779
+ function createConnectorError(statusCode, message) {
1780
+ const error = new Error(message);
1781
+ error.statusCode = statusCode;
1782
+ return error;
1783
+ }
1784
+
1785
+ function httpRequest({ url, method, headers, body, timeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS }) {
1786
+ const transport = url.protocol === 'https:' ? https : http;
1787
+
1788
+ return new Promise((resolve, reject) => {
1789
+ const request = transport.request(
1790
+ url,
1791
+ {
1792
+ method,
1793
+ headers,
1794
+ },
1795
+ (response) => {
1796
+ const chunks = [];
1797
+ response.on('data', (chunk) => chunks.push(chunk));
1798
+ response.on('end', () => {
1799
+ resolve({
1800
+ statusCode: response.statusCode || 502,
1801
+ headers: sanitizeHeaders(response.headers),
1802
+ body: Buffer.concat(chunks),
1803
+ });
1804
+ });
1805
+ },
1806
+ );
1807
+
1808
+ request.setTimeout(timeoutMs, () => {
1809
+ const error = new Error(`upstream timeout after ${timeoutMs}ms`);
1810
+ error.code = 'ETIMEDOUT';
1811
+ request.destroy(error);
1812
+ });
1813
+
1814
+ request.on('error', reject);
1815
+ if (body?.length) {
1816
+ request.write(body);
1817
+ }
1818
+ request.end();
1819
+ });
1820
+ }
1821
+
1822
+ async function forwardChatWithFallback({
1823
+ openclawUrl,
1824
+ requestedPath,
1825
+ preferredPath,
1826
+ candidatePaths,
1827
+ upstreamTimeoutMs,
1828
+ headers,
1829
+ body,
1830
+ }) {
1831
+ const paths = dedupePaths([
1832
+ requestedPath,
1833
+ preferredPath,
1834
+ ...candidatePaths,
1835
+ ...CHAT_PATH_FALLBACKS,
1836
+ ]);
1837
+ let lastResult = null;
1838
+
1839
+ for (const path of paths) {
1840
+ const url = new URL(path, `${openclawUrl}/`);
1841
+ const adaptedBody = adaptRequestBodyForPath(body, path);
1842
+ let result;
1843
+ try {
1844
+ result = await httpRequest({
1845
+ url,
1846
+ method: 'POST',
1847
+ headers,
1848
+ body: adaptedBody,
1849
+ timeoutMs: upstreamTimeoutMs,
1850
+ });
1851
+ } catch (error) {
1852
+ if (isUpstreamTimeout(error)) {
1853
+ return {
1854
+ statusCode: 504,
1855
+ headers: { 'content-type': 'application/json; charset=utf-8' },
1856
+ body: Buffer.from(
1857
+ JSON.stringify(
1858
+ {
1859
+ error: 'upstream_timeout',
1860
+ message: `OpenClaw upstream timeout after ${upstreamTimeoutMs}ms`,
1861
+ chatPath: path,
1862
+ },
1863
+ null,
1864
+ 2,
1865
+ ),
1866
+ ),
1867
+ chatPath: path,
1868
+ };
1869
+ }
1870
+ throw error;
1871
+ }
1872
+ if (result.statusCode !== 404) {
1873
+ return { ...result, chatPath: path };
1874
+ }
1875
+ lastResult = { ...result, chatPath: path };
1876
+ }
1877
+
1878
+ return (
1879
+ lastResult || {
1880
+ statusCode: 404,
1881
+ headers: { 'content-type': 'application/json; charset=utf-8' },
1882
+ body: Buffer.from(
1883
+ JSON.stringify(
1884
+ {
1885
+ error: 'upstream_not_found',
1886
+ message: 'No compatible upstream chat path was found.',
1887
+ },
1888
+ null,
1889
+ 2,
1890
+ ),
1891
+ ),
1892
+ chatPath: requestedPath,
1893
+ }
1894
+ );
1895
+ }
1896
+
1897
+ function isUpstreamTimeout(error) {
1898
+ if (!error) return false;
1899
+ const message = `${error.message || ''}`.toLowerCase();
1900
+ return error.code === 'ETIMEDOUT' || message.includes('timeout');
1901
+ }
1902
+
1903
+ function adaptRequestBodyForPath(body, path) {
1904
+ if (normalizePath(path) !== '/v1/responses') {
1905
+ return body;
1906
+ }
1907
+ let parsed;
1908
+ try {
1909
+ parsed = JSON.parse(body.toString('utf8'));
1910
+ } catch {
1911
+ return body;
1912
+ }
1913
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1914
+ return body;
1915
+ }
1916
+ if (parsed.input != null) {
1917
+ return body;
1918
+ }
1919
+ return Buffer.from(JSON.stringify(toResponsesPayload(parsed)));
1920
+ }
1921
+
1922
+ function toResponsesPayload(chatPayload) {
1923
+ const messages = toMessageList(chatPayload.messages);
1924
+ const instructions = firstSystemInstruction(messages);
1925
+ const input = toResponsesInput(messages);
1926
+ return {
1927
+ model: chatPayload.model || 'openclaw',
1928
+ stream: false,
1929
+ ...(instructions ? { instructions } : {}),
1930
+ input,
1931
+ ...(chatPayload.metadata && typeof chatPayload.metadata === 'object' ?
1932
+ { metadata: chatPayload.metadata } :
1933
+ {}),
1934
+ };
1935
+ }
1936
+
1937
+ function toMessageList(raw) {
1938
+ if (!Array.isArray(raw)) return [];
1939
+ return raw.filter((item) => item && typeof item === 'object' && !Array.isArray(item));
1940
+ }
1941
+
1942
+ function firstSystemInstruction(messages) {
1943
+ for (const item of messages) {
1944
+ const role = `${item.role || ''}`.trim().toLowerCase();
1945
+ if (role !== 'system') continue;
1946
+ const text = toText(item.content);
1947
+ if (text) return text;
1948
+ }
1949
+ return '';
1950
+ }
1951
+
1952
+ function toResponsesInput(messages) {
1953
+ const list = [];
1954
+ for (const item of messages) {
1955
+ const role = `${item.role || ''}`.trim().toLowerCase();
1956
+ if (role === 'system') continue;
1957
+ const normalizedRole = role === 'assistant' ? 'assistant' : 'user';
1958
+ const content = item.content;
1959
+ if (Array.isArray(content)) {
1960
+ list.push({
1961
+ role: normalizedRole,
1962
+ content,
1963
+ });
1964
+ continue;
1965
+ }
1966
+ const text = toText(content);
1967
+ if (!text) continue;
1968
+ list.push({
1969
+ role: normalizedRole,
1970
+ content: [
1971
+ {
1972
+ type: 'input_text',
1973
+ text,
1974
+ },
1975
+ ],
1976
+ });
1977
+ }
1978
+ if (list.length > 0) return list;
1979
+ const fallback = messages.length ? toText(messages[messages.length - 1].content) : '';
1980
+ return fallback;
1981
+ }
1982
+
1983
+ function toText(value) {
1984
+ if (value == null) return '';
1985
+ if (typeof value === 'string') return value;
1986
+ if (Array.isArray(value)) {
1987
+ return value.map((part) => toText(part)).filter((part) => part.trim()).join('\n');
1988
+ }
1989
+ if (typeof value === 'object') {
1990
+ return toText(value.text ?? value.content ?? value.value ?? value.output_text);
1991
+ }
1992
+ return `${value}`;
1993
+ }
1994
+
1995
+ function sanitizeHeaders(headers) {
1996
+ const next = {};
1997
+ for (const [key, value] of Object.entries(headers)) {
1998
+ if (value == null) {
1999
+ continue;
2000
+ }
2001
+ if (Array.isArray(value)) {
2002
+ next[key] = value.join(', ');
2003
+ } else {
2004
+ next[key] = value;
2005
+ }
2006
+ }
2007
+ return next;
2008
+ }
2009
+
2010
+ function normalizeChatMode(value) {
2011
+ const mode = `${value || ''}`.trim().toLowerCase();
2012
+ if (!mode) return DEFAULT_CHAT_MODE;
2013
+ if (mode === 'gateway-session' || mode === 'http-proxy') {
2014
+ return mode;
2015
+ }
2016
+ throw new Error(`Invalid chat-mode: ${value}. Use gateway-session or http-proxy.`);
2017
+ }
2018
+
2019
+ function normalizeSessionSegment(value) {
2020
+ const trimmed = `${value || ''}`.trim();
2021
+ if (!trimmed) return 'main';
2022
+ return trimmed.replace(/[^a-zA-Z0-9_-]/g, '-') || 'main';
2023
+ }
2024
+
2025
+ function normalizeSessionKey(value) {
2026
+ const trimmed = `${value || ''}`.trim();
2027
+ if (!trimmed) return DEFAULT_SESSION_KEY;
2028
+ return trimmed;
2029
+ }
2030
+
2031
+ function stripTrailingSlash(value) {
2032
+ return value.replace(/\/+$/, '');
2033
+ }
2034
+
2035
+ function normalizePath(value) {
2036
+ if (!value) {
2037
+ return DEFAULT_CHAT_PATH;
2038
+ }
2039
+ const withLeading = value.startsWith('/') ? value : `/${value}`;
2040
+ if (withLeading.length > 1 && withLeading.endsWith('/')) {
2041
+ return withLeading.slice(0, -1);
2042
+ }
2043
+ return withLeading;
2044
+ }
2045
+
2046
+ function dedupePaths(paths) {
2047
+ const result = [];
2048
+ const seen = new Set();
2049
+ for (const path of paths) {
2050
+ const normalized = normalizePath(path);
2051
+ if (seen.has(normalized)) continue;
2052
+ seen.add(normalized);
2053
+ result.push(normalized);
2054
+ }
2055
+ return result;
2056
+ }
2057
+
2058
+ function isChatPathRequest(pathname, method, acceptedChatPaths) {
2059
+ if (method !== 'POST') return false;
2060
+ const normalized = normalizePath(pathname || '/');
2061
+ return acceptedChatPaths.includes(normalized);
2062
+ }
2063
+
2064
+ function detectLanAddress() {
2065
+ return listLanCandidates()[0]?.address || '';
2066
+ }
2067
+
2068
+ function listLanCandidates() {
2069
+ const interfaces = os.networkInterfaces();
2070
+ const candidates = [];
2071
+
2072
+ for (const [iface, addresses] of Object.entries(interfaces)) {
2073
+ for (const item of addresses || []) {
2074
+ if (item.family !== 'IPv4' || item.internal) {
2075
+ continue;
2076
+ }
2077
+
2078
+ const score = scoreLanCandidate(iface, item.address);
2079
+ candidates.push({
2080
+ iface,
2081
+ address: item.address,
2082
+ score,
2083
+ });
2084
+ }
2085
+ }
2086
+
2087
+ candidates.sort((a, b) => b.score - a.score);
2088
+ return candidates;
2089
+ }
2090
+
2091
+ function scoreLanCandidate(iface, address) {
2092
+ const name = iface.toLowerCase();
2093
+ let score = 0;
2094
+
2095
+ if (isPrivateIpv4(address)) {
2096
+ score += 60;
2097
+ } else {
2098
+ score += 5;
2099
+ }
2100
+
2101
+ if (isSpecialUseIpv4(address)) {
2102
+ score -= 180;
2103
+ }
2104
+
2105
+ if (isBenchmarkOrDocumentationIpv4(address)) {
2106
+ score -= 120;
2107
+ }
2108
+
2109
+ if (
2110
+ name.includes('wi-fi') ||
2111
+ name.includes('wifi') ||
2112
+ name.includes('wlan') ||
2113
+ name.includes('wireless') ||
2114
+ name.startsWith('en') ||
2115
+ name.startsWith('wl')
2116
+ ) {
2117
+ score += 30;
2118
+ }
2119
+
2120
+ if (name.includes('ethernet') || name.startsWith('eth')) {
2121
+ score += 20;
2122
+ }
2123
+
2124
+ if (
2125
+ name.includes('virtual') ||
2126
+ name.includes('vmware') ||
2127
+ name.includes('vbox') ||
2128
+ name.includes('hyper-v') ||
2129
+ name.includes('docker') ||
2130
+ name.includes('tailscale') ||
2131
+ name.includes('zerotier') ||
2132
+ name.includes('hamachi') ||
2133
+ name.includes('clash') ||
2134
+ name.includes('tun') ||
2135
+ name.includes('tap') ||
2136
+ name.includes('vpn') ||
2137
+ name.includes('proxy') ||
2138
+ name.includes('wireguard') ||
2139
+ name.includes('wg') ||
2140
+ name.includes('loopback') ||
2141
+ name.includes('utun') ||
2142
+ name.includes('veth')
2143
+ ) {
2144
+ score -= 100;
2145
+ }
2146
+
2147
+ return score;
2148
+ }
2149
+
2150
+ function isPrivateIpv4(address) {
2151
+ if (address.startsWith('10.')) return true;
2152
+ if (address.startsWith('192.168.')) return true;
2153
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) return true;
2154
+ return false;
2155
+ }
2156
+
2157
+ function parseIpv4(address) {
2158
+ const parts = `${address || ''}`.trim().split('.');
2159
+ if (parts.length !== 4) return null;
2160
+ const octets = parts.map((part) => Number.parseInt(part, 10));
2161
+ if (octets.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) {
2162
+ return null;
2163
+ }
2164
+ return octets;
2165
+ }
2166
+
2167
+ function ipv4ToUInt32(octets) {
2168
+ return (
2169
+ (((octets[0] << 24) >>> 0) |
2170
+ (octets[1] << 16) |
2171
+ (octets[2] << 8) |
2172
+ octets[3]) >>> 0
2173
+ );
2174
+ }
2175
+
2176
+ function isIpv4InCidr(address, baseAddress, prefixLength) {
2177
+ const ip = parseIpv4(address);
2178
+ const base = parseIpv4(baseAddress);
2179
+ if (!ip || !base) return false;
2180
+ if (prefixLength <= 0) return true;
2181
+ if (prefixLength >= 32) return ipv4ToUInt32(ip) === ipv4ToUInt32(base);
2182
+
2183
+ const shift = 32 - prefixLength;
2184
+ const mask = ((0xFFFFFFFF << shift) >>> 0);
2185
+ return (
2186
+ (ipv4ToUInt32(ip) & mask) === (ipv4ToUInt32(base) & mask)
2187
+ );
2188
+ }
2189
+
2190
+ function isBenchmarkOrDocumentationIpv4(address) {
2191
+ return (
2192
+ isIpv4InCidr(address, '198.18.0.0', 15) ||
2193
+ isIpv4InCidr(address, '192.0.2.0', 24) ||
2194
+ isIpv4InCidr(address, '198.51.100.0', 24) ||
2195
+ isIpv4InCidr(address, '203.0.113.0', 24)
2196
+ );
2197
+ }
2198
+
2199
+ function isSpecialUseIpv4(address) {
2200
+ return (
2201
+ isBenchmarkOrDocumentationIpv4(address) ||
2202
+ isIpv4InCidr(address, '100.64.0.0', 10) ||
2203
+ isIpv4InCidr(address, '127.0.0.0', 8) ||
2204
+ isIpv4InCidr(address, '169.254.0.0', 16) ||
2205
+ isIpv4InCidr(address, '224.0.0.0', 4) ||
2206
+ isIpv4InCidr(address, '240.0.0.0', 4) ||
2207
+ isIpv4InCidr(address, '0.0.0.0', 8)
2208
+ );
2209
+ }
2210
+
2211
+ function printBanner(config) {
2212
+ console.log('');
2213
+ console.log('MateClaw Local OpenClaw Demo Connector');
2214
+ console.log('======================================');
2215
+ console.log(`Upstream OpenClaw : ${config.openclawUrl}`);
2216
+ console.log(`Chat Mode : ${config.chatMode}`);
2217
+ console.log(`Session ID : ${config.sessionKey}`);
2218
+ console.log(`Chat Path : ${config.chatPath}`);
2219
+ console.log(`Accepted Paths : ${config.acceptedChatPaths.join(', ')}`);
2220
+ console.log(`Desktop Bind Host : ${config.listenHost}:${config.port}`);
2221
+ console.log(`Mobile Access URL : ${config.baseUrl}`);
2222
+ console.log(`Agent ID : ${config.agentId}`);
2223
+ console.log(`Token : ${config.token}`);
2224
+ console.log(`Expires At : ${config.expiresAt.toISOString()}`);
2225
+ console.log(`Upstream Timeout : ${config.upstreamTimeoutMs}ms`);
2226
+ console.log(
2227
+ `Upstream Auth : ${
2228
+ config.upstreamToken ? `enabled (${config.upstreamToken.slice(0, 6)}...)` : 'disabled'
2229
+ }`,
2230
+ );
2231
+ if (config.lanCandidates?.length) {
2232
+ console.log('Detected IPv4 NICs:');
2233
+ for (const item of config.lanCandidates) {
2234
+ console.log(` - ${item.address} (${item.iface}, score=${item.score})`);
2235
+ }
2236
+ }
2237
+ console.log('');
2238
+ console.log('FoloToy-style bind flow: install once -> scan the QR shown here -> chat.');
2239
+ console.log('If binding fails, pin the LAN IP manually:');
2240
+ console.log(
2241
+ ` node ./src/cli.mjs install --lan-host ${config.lanHost || '<your-lan-ip>'} --port ${config.port}`,
2242
+ );
2243
+ console.log(`Quick health URL: ${config.baseUrl}/health`);
2244
+ console.log('');
2245
+ console.log('Scan this QR code in the MateClaw app (this is the OpenClaw-side bind QR):');
2246
+ console.log('');
2247
+ qrcode.generate(JSON.stringify(config.qrPayload), { small: true });
2248
+ console.log('');
2249
+ console.log('If QR scanning is inconvenient, copy this payload instead:');
2250
+ console.log(JSON.stringify(config.qrPayload));
2251
+ console.log('');
2252
+ }
2253
+
2254
+ main().catch((error) => {
2255
+ console.error(error.message || error);
2256
+ process.exitCode = 1;
2257
+ });