mateclaw-openclaw-plugin 0.1.1 → 0.1.4

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 -21
  2. package/README.md +128 -64
  3. package/package.json +38 -36
  4. package/src/cli.mjs +631 -16
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 MateClaw
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MateClaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,92 +1,156 @@
1
- # mateclaw-openclaw-plugin
2
-
3
- [![npm version](https://img.shields.io/npm/v/mateclaw-openclaw-plugin?label=npm)](https://www.npmjs.com/package/mateclaw-openclaw-plugin)
4
- [![node](https://img.shields.io/node/v/mateclaw-openclaw-plugin)](https://www.npmjs.com/package/mateclaw-openclaw-plugin)
5
- [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
-
7
- This package provides a folotoy-style one-command local setup for MateClaw demos:
8
-
1
+ # mateclaw-openclaw-plugin
2
+
3
+ [![npm version](https://img.shields.io/npm/v/mateclaw-openclaw-plugin?label=npm)](https://www.npmjs.com/package/mateclaw-openclaw-plugin)
4
+ [![node](https://img.shields.io/node/v/mateclaw-openclaw-plugin)](https://www.npmjs.com/package/mateclaw-openclaw-plugin)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+
7
+ This package provides a one-command local setup for MateClaw app-only flow:
8
+
9
9
  - `doctor` checks OpenClaw, gateway, token, identity, LAN, and connector port
10
- - `install` runs `doctor + auto-fix + optional gateway restart + connector start`
11
- - connector outputs QR code and keeps app chat in the same gateway session
12
-
13
- ## Customer one-command flow
14
-
10
+ - `install` runs `doctor + auto-fix + optional gateway restart + connector service start`
11
+ - by default connector runs in background service, so CMD can be closed after install
12
+
13
+ ## Customer one-command flow
14
+
15
15
  Published package usage:
16
16
 
17
17
  ```bash
18
- npx -y mateclaw-openclaw-plugin install --chat-mode gateway-session --session-key agent:main:main
19
- ```
20
-
21
- Local repo usage:
22
-
23
- ```bash
24
- cd mateclaw-openclaw-plugin
25
- npm install
26
- node ./src/cli.mjs install --chat-mode gateway-session --session-key agent:main:main
18
+ npx -y mateclaw-openclaw-plugin install
27
19
  ```
28
20
 
29
- After startup:
21
+ This preset command auto-uses:
22
+ - `chat-mode=gateway-session`
23
+ - `session-key=agent:main:main`
24
+ - built-in reverse tunnel
25
+ - tunnel target `xxx@xxx`
26
+ - public bind URL `https://xxx`
30
27
 
31
- 1. Scan the OpenClaw-side QR code shown by the installer in MateClaw app.
32
- 2. Start text chat directly in app.
33
- 3. Optional desktop same-session view:
34
- `http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain`
28
+ You will be prompted for tunnel password with hidden input.
29
+ If you only want LAN (no tunnel): `npx -y mateclaw-openclaw-plugin install --lan-only`
35
30
 
36
- ## What `install` auto-handles
37
-
38
- - Checks OpenClaw CLI version and gateway health/status
39
- - Checks and repairs `~/.openclaw/openclaw.json` (`gateway.mode`, `gateway.auth.mode`, token/password)
40
- - Auto-backs up existing `openclaw.json` before writing
41
- - Checks and auto-creates device identity (`~/.openclaw/identity/device.json`) when missing
42
- - Checks and syncs device auth token (`~/.openclaw/identity/device-auth.json`) when missing
43
- - Auto-selects current LAN IPv4
44
- - Auto-switches connector port when preferred port is occupied
45
- - Optionally restarts gateway (`openclaw gateway restart`)
46
- - Starts connector and prints binding QR
47
-
48
- ## Commands
31
+ Background service commands:
49
32
 
50
33
  ```bash
34
+ npx -y mateclaw-openclaw-plugin service-status
35
+ npx -y mateclaw-openclaw-plugin service-stop
36
+ ```
37
+
38
+ Local repo usage:
39
+
40
+ ```bash
41
+ cd mateclaw-openclaw-plugin
42
+ npm install
43
+ node ./src/cli.mjs install --chat-mode gateway-session --session-key agent:main:main
44
+ ```
45
+
46
+ After startup:
47
+
48
+ 1. Scan the OpenClaw-side QR code shown by the installer in MateClaw app.
49
+ 2. Start text chat directly in app.
50
+ 3. Optional desktop same-session view:
51
+ `http://127.0.0.1:18789/chat?session=agent%3Amain%3Amain`
52
+
53
+ ## What `install` auto-handles
54
+
55
+ - Checks OpenClaw CLI version and gateway health/status
56
+ - Checks and repairs `~/.openclaw/openclaw.json` (`gateway.mode`, `gateway.auth.mode`, token/password)
57
+ - Auto-backs up existing `openclaw.json` before writing
58
+ - Checks and auto-creates device identity (`~/.openclaw/identity/device.json`) when missing
59
+ - Checks and syncs device auth token (`~/.openclaw/identity/device-auth.json`) when missing
60
+ - Auto-selects current LAN IPv4
61
+ - Auto-switches connector port when preferred port is occupied
62
+ - Optionally restarts gateway (`openclaw gateway restart`)
63
+ - Starts connector and prints binding QR
64
+
65
+ ## Commands
66
+
67
+ ```bash
51
68
  node ./src/cli.mjs doctor
52
69
  node ./src/cli.mjs install
53
70
  node ./src/cli.mjs connect
54
71
  node ./src/cli.mjs login install # alias of install
72
+ node ./src/cli.mjs service-status
73
+ node ./src/cli.mjs service-stop
55
74
  ```
75
+
76
+ ## Useful options
77
+
78
+ ```bash
79
+ node ./src/cli.mjs install \
80
+ --openclaw-url http://127.0.0.1:18789 \
81
+ --chat-mode gateway-session \
82
+ --session-key agent:main:main \
83
+ --port 18890
84
+ ```
85
+
86
+ - `--openclaw-bin <cmd>`: override OpenClaw CLI command
87
+ - `--config-path <path>`: override openclaw config path
88
+ - `--skip-gateway-restart`: skip `openclaw gateway restart`
89
+ - `--skip-connector`: only run setup, do not start connector
90
+ - `--foreground`: run connector in current CMD (do not use background service)
91
+ - `--dry-run`: print planned fixes without writing files
92
+ - `--lan-host <ip>`: pin LAN IP manually
93
+ - `--public-base-url <url>`: override QR baseUrl with public URL
94
+ - `--tunnel`: enable reverse tunnel
95
+ - `--lan-only`: disable tunnel and force LAN-only QR/baseUrl
96
+ - `--tunnel-client <builtin|ssh|nssh>`: choose tunnel client (default: `builtin`)
97
+ - `--tunnel-user / --tunnel-host`: tunnel account and host
98
+ - `--tunnel-password`: required by `builtin` and `nssh`
99
+
100
+ ## Intranet tunnel (internal provider)
56
101
 
57
- ## Useful options
102
+ Example (one-command, no extra tunnel client install, use `builtin`):
58
103
 
59
104
  ```bash
60
105
  node ./src/cli.mjs install \
61
- --openclaw-url http://127.0.0.1:18789 \
62
106
  --chat-mode gateway-session \
63
107
  --session-key agent:main:main \
64
- --port 18890
108
+ --tunnel \
109
+ --tunnel-client builtin \
110
+ --tunnel-user xxx \
111
+ --tunnel-host xxx \
112
+ --tunnel-remote-port 80 \
113
+ --public-base-url https://xxx
65
114
  ```
66
115
 
67
- - `--openclaw-bin <cmd>`: override OpenClaw CLI command
68
- - `--config-path <path>`: override openclaw config path
69
- - `--skip-gateway-restart`: skip `openclaw gateway restart`
70
- - `--skip-connector`: only run setup, do not start connector
71
- - `--dry-run`: print planned fixes without writing files
72
- - `--lan-host <ip>`: pin LAN IP manually
116
+ If `--tunnel-password` is omitted, CLI will ask for hidden password input interactively.
73
117
 
74
- ## Development scripts
118
+ Alternative external client forms:
75
119
 
76
120
  ```bash
77
- npm run doctor
78
- npm run install:local
79
- npm run connect
80
- npm run check:publish
81
- npm run pack:dry-run
82
- npm run publish:dry-run
121
+ ssh -R 80:127.0.0.1:18890 xxx@xxx
122
+ nssh -R 80:127.0.0.1:18890 xxx@xxx --passwd <password>
83
123
  ```
84
124
 
85
- ## Publish
86
-
87
- ```bash
88
- npm login
89
- npm run check:publish
90
- npm run publish:dry-run
91
- npm run publish:public
92
- ```
125
+ ## Security note (next phase)
126
+
127
+ Current simple mode still needs customer-side tunnel credentials.
128
+ Planned upgrade is backend-issued short-lived tunnel tickets, so no long-term shared password is exposed to customers.
129
+
130
+ ## Development scripts
131
+
132
+ ```bash
133
+ npm run doctor
134
+ npm run install:local
135
+ npm run connect
136
+ npm run check:publish
137
+ npm run pack:dry-run
138
+ npm run publish:dry-run
139
+ ```
140
+
141
+ ## Publish
142
+
143
+ ```bash
144
+ npm login
145
+ npm run check:publish
146
+ npm run publish:dry-run
147
+ npm run publish:public
148
+ ```
149
+
150
+ Use Automation Token (no OTP on publish):
151
+
152
+ ```bash
153
+ # PowerShell
154
+ $env:NPM_TOKEN = "npm_xxx_your_automation_token"
155
+ npm run publish:token
156
+ ```
package/package.json CHANGED
@@ -1,36 +1,38 @@
1
- {
2
- "name": "mateclaw-openclaw-plugin",
3
- "version": "0.1.1",
4
- "private": false,
5
- "type": "module",
6
- "description": "Local OpenClaw connector for MateClaw customer demos",
7
- "license": "MIT",
8
- "bin": {
9
- "mateclaw-openclaw-plugin": "src/cli.mjs"
10
- },
11
- "files": [
12
- "src",
13
- "README.md",
14
- "LICENSE"
15
- ],
16
- "publishConfig": {
17
- "access": "public"
18
- },
19
- "scripts": {
20
- "doctor": "node ./src/cli.mjs doctor",
21
- "install:local": "node ./src/cli.mjs install",
22
- "connect": "node ./src/cli.mjs connect",
23
- "check:publish": "node ./scripts/prepublish-check.mjs",
24
- "pack:dry-run": "npm pack --dry-run",
25
- "publish:dry-run": "npm publish --dry-run --access public",
26
- "publish:public": "npm publish --access public",
27
- "prepublishOnly": "npm run check:publish"
28
- },
29
- "dependencies": {
30
- "qrcode-terminal": "^0.12.0",
31
- "ws": "^8.18.3"
32
- },
33
- "engines": {
34
- "node": ">=18"
35
- }
36
- }
1
+ {
2
+ "name": "mateclaw-openclaw-plugin",
3
+ "version": "0.1.4",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Local OpenClaw connector for MateClaw customer demos",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "mateclaw-openclaw-plugin": "src/cli.mjs"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "doctor": "node ./src/cli.mjs doctor",
21
+ "install:local": "node ./src/cli.mjs install",
22
+ "connect": "node ./src/cli.mjs connect",
23
+ "check:publish": "node ./scripts/prepublish-check.mjs",
24
+ "pack:dry-run": "npm pack --dry-run",
25
+ "publish:dry-run": "npm publish --dry-run --access public",
26
+ "publish:public": "npm publish --access public",
27
+ "publish:token": "node ./scripts/publish-with-token.mjs",
28
+ "prepublishOnly": "npm run check:publish"
29
+ },
30
+ "dependencies": {
31
+ "qrcode-terminal": "^0.12.0",
32
+ "ssh2": "^1.17.0",
33
+ "ws": "^8.18.3"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }
package/src/cli.mjs CHANGED
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import crypto from 'node:crypto';
4
- import { spawnSync } from 'node:child_process';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import http from 'node:http';
7
7
  import https from 'node:https';
8
8
  import net from 'node:net';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
- import { URL } from 'node:url';
11
+ import readline from 'node:readline';
12
+ import { URL, fileURLToPath } from 'node:url';
12
13
  import qrcode from 'qrcode-terminal';
14
+ import { Client as SshClient } from 'ssh2';
13
15
  import { WebSocket } from 'ws';
14
16
 
15
17
  const DEFAULT_OPENCLAW_URL = 'http://127.0.0.1:18789';
@@ -27,8 +29,18 @@ const DEFAULT_PORT = 18890;
27
29
  const DEFAULT_AGENT_ID = 'main';
28
30
  const DEFAULT_EXPIRES_MINUTES = 720;
29
31
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 180000;
30
- const PAYLOAD_TYPE = 'mateclaw_local_openclaw_demo';
32
+ const PAYLOAD_TYPE = 'mateclaw_openclaw_plugin';
31
33
  const DEFAULT_OPENCLAW_BIN = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw';
34
+ const DEFAULT_TUNNEL_CLIENT = 'builtin';
35
+ const DEFAULT_TUNNEL_SERVER_PORT = 22;
36
+ const DEFAULT_TUNNEL_REMOTE_PORT = 80;
37
+ const DEFAULT_TUNNEL_LOCAL_HOST = '127.0.0.1';
38
+ const DEFAULT_PRESET_TUNNEL_ENABLED = true;
39
+ const DEFAULT_PRESET_TUNNEL_USER = '6111605006';
40
+ const DEFAULT_PRESET_TUNNEL_HOST = 'gz2.neiwangyun.net';
41
+ const DEFAULT_PRESET_TUNNEL_PASSWORD = 'skg5gm';
42
+ const DEFAULT_PRESET_PUBLIC_BASE_URL = 'https://6hjduygz2.neiwangyun.net';
43
+ const CURRENT_CLI_PATH = fileURLToPath(import.meta.url);
32
44
 
33
45
  async function main() {
34
46
  const rawArgs = process.argv.slice(2);
@@ -37,7 +49,7 @@ async function main() {
37
49
 
38
50
  switch (resolved.command) {
39
51
  case 'connect': {
40
- const config = buildConfig(options);
52
+ const config = await buildConfig(options);
41
53
  await startConnector(config);
42
54
  return;
43
55
  }
@@ -49,6 +61,14 @@ async function main() {
49
61
  await runDoctor(options);
50
62
  return;
51
63
  }
64
+ case 'service-stop': {
65
+ await runServiceStop();
66
+ return;
67
+ }
68
+ case 'service-status': {
69
+ await runServiceStatus();
70
+ return;
71
+ }
52
72
  case 'help':
53
73
  printHelp();
54
74
  return;
@@ -79,8 +99,10 @@ function printHelp() {
79
99
  console.log('');
80
100
  console.log('Commands:');
81
101
  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)');
102
+ console.log(' login install Alias of install');
83
103
  console.log(' connect Start connector directly (no config repair)');
104
+ console.log(' service-stop Stop background connector service started by install');
105
+ console.log(' service-status Show background connector service status');
84
106
  console.log(' doctor Check OpenClaw and local config health');
85
107
  console.log('');
86
108
  console.log('Shared options (connect/install):');
@@ -97,13 +119,28 @@ function printHelp() {
97
119
  console.log(' --upstream-timeout-ms <ms> Upstream OpenClaw request timeout');
98
120
  console.log(' --chat-mode <mode> gateway-session | http-proxy');
99
121
  console.log(' --session-key <key> Fixed Gateway session key (default: agent:<agent-id>:main)');
122
+ console.log(' --public-base-url <url> Override QR baseUrl with a public URL');
123
+ console.log(' --tunnel Enable reverse tunnel (ssh/nssh)');
124
+ console.log(' --lan-only Force LAN-only mode (disable tunnel/public URL)');
125
+ console.log(' --tunnel-client <name> builtin | ssh | nssh (default: builtin)');
126
+ console.log(' builtin uses embedded SSH reverse tunnel (no nssh install needed)');
127
+ console.log(' --tunnel-bin <cmd> Tunnel client command path');
128
+ console.log(' --tunnel-user <user> Tunnel account user');
129
+ console.log(' --tunnel-host <host> Tunnel server host');
130
+ console.log(' --tunnel-server-port <n> SSH service port (default: 22)');
131
+ console.log(' --tunnel-remote-port <n> Public exposed port (default: 80)');
132
+ console.log(' --tunnel-password <pwd> tunnel password for builtin/nssh (or set MATECLAW_TUNNEL_PASSWORD)');
100
133
  console.log('');
101
134
  console.log('Install/Doctor options:');
102
135
  console.log(' --openclaw-bin <cmd> OpenClaw CLI command (default: openclaw/openclaw.cmd)');
103
136
  console.log(' --config-path <path> Override openclaw.json path');
104
137
  console.log(' --skip-gateway-restart Skip openclaw gateway restart after config updates');
105
138
  console.log(' --skip-connector Only setup config, do not start local connector');
139
+ console.log(' --foreground Keep connector in current CMD (no background service)');
106
140
  console.log(' --dry-run Validate and print changes without writing files');
141
+ console.log('');
142
+ console.log('Quick start (preset tunnel defaults + hidden password prompt):');
143
+ console.log(' npx -y mateclaw-openclaw-plugin install');
107
144
  }
108
145
 
109
146
  function parseArgs(args) {
@@ -224,6 +261,7 @@ function runOpenClawCommand(bin, args, timeoutMs = 20000) {
224
261
  encoding: 'utf8',
225
262
  timeout: timeoutMs,
226
263
  shell: process.platform === 'win32',
264
+ windowsHide: true,
227
265
  });
228
266
  const stdout = `${result.stdout || ''}`.trim();
229
267
  const stderr = `${result.stderr || ''}`.trim();
@@ -263,6 +301,41 @@ function summarizeCommandFailure(result) {
263
301
  return detail || `exit code ${result.status}`;
264
302
  }
265
303
 
304
+ function isCommandAvailable(commandName) {
305
+ const name = `${commandName || ''}`.trim();
306
+ if (!name) return false;
307
+ const probeTool = process.platform === 'win32' ? 'where' : 'which';
308
+ const probe = runOpenClawCommand(probeTool, [name], 8000);
309
+ return probe.ok && `${probe.stdout || ''}`.trim().length > 0;
310
+ }
311
+
312
+ async function readSecretFromPrompt(promptText) {
313
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
314
+ return '';
315
+ }
316
+ const rl = readline.createInterface({
317
+ input: process.stdin,
318
+ output: process.stdout,
319
+ terminal: true,
320
+ });
321
+ const originalWrite = rl._writeToOutput;
322
+ rl._writeToOutput = function writeMaskedOutput(stringToWrite) {
323
+ if (rl.stdoutMuted) {
324
+ rl.output.write('*');
325
+ return;
326
+ }
327
+ originalWrite.call(rl, stringToWrite);
328
+ };
329
+ rl.stdoutMuted = true;
330
+ const secret = await new Promise((resolve) => {
331
+ rl.question(promptText, (answer) => resolve(`${answer || ''}`.trim()));
332
+ });
333
+ rl.stdoutMuted = false;
334
+ rl.close();
335
+ process.stdout.write('\n');
336
+ return secret;
337
+ }
338
+
266
339
  function extractGatewayTokenFromConfig(config) {
267
340
  const mode = `${config?.gateway?.auth?.mode || ''}`.trim().toLowerCase();
268
341
  if (mode === 'password') {
@@ -278,6 +351,11 @@ function maskToken(token) {
278
351
  return `${text.slice(0, 4)}...${text.slice(-2)}`;
279
352
  }
280
353
 
354
+ function maskTunnelPasswordInCommand(commandText) {
355
+ if (!commandText) return '';
356
+ return commandText.replace(/(--passwd\s+)([^\s]+)/i, '$1***');
357
+ }
358
+
281
359
  function parsePortOrDefault(raw, fallback) {
282
360
  const parsed = Number.parseInt(`${raw ?? ''}`, 10);
283
361
  if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
@@ -911,6 +989,135 @@ async function runDoctor(options) {
911
989
  }
912
990
  }
913
991
 
992
+ function buildConnectArgsFromConfig(config) {
993
+ const args = [
994
+ 'connect',
995
+ '--openclaw-url', `${config.openclawUrl}`,
996
+ '--chat-path', `${config.chatPath}`,
997
+ '--host', `${config.listenHost}`,
998
+ '--port', `${config.port}`,
999
+ '--lan-host', `${config.lanHost}`,
1000
+ '--agent-id', `${config.agentId}`,
1001
+ '--name', `${config.name}`,
1002
+ '--token', `${config.token}`,
1003
+ '--expires-minutes', `${Math.max(1, Math.ceil((config.expiresAt.getTime() - Date.now()) / 60000))}`,
1004
+ '--upstream-timeout-ms', `${config.upstreamTimeoutMs}`,
1005
+ '--chat-mode', `${config.chatMode}`,
1006
+ '--session-key', `${config.sessionKey}`,
1007
+ ];
1008
+
1009
+ if (config.upstreamToken) {
1010
+ args.push('--upstream-token', `${config.upstreamToken}`);
1011
+ }
1012
+ if (config.lanOnly) {
1013
+ args.push('--lan-only');
1014
+ }
1015
+ if (config.publicBaseUrl) {
1016
+ args.push('--public-base-url', `${config.publicBaseUrl}`);
1017
+ }
1018
+ if (config.tunnel?.enabled) {
1019
+ args.push('--tunnel');
1020
+ args.push('--tunnel-client', `${config.tunnel.client}`);
1021
+ if (config.tunnel.bin) args.push('--tunnel-bin', `${config.tunnel.bin}`);
1022
+ if (config.tunnel.user) args.push('--tunnel-user', `${config.tunnel.user}`);
1023
+ if (config.tunnel.host) args.push('--tunnel-host', `${config.tunnel.host}`);
1024
+ if (config.tunnel.password) args.push('--tunnel-password', `${config.tunnel.password}`);
1025
+ if (config.tunnel.serverPort) args.push('--tunnel-server-port', `${config.tunnel.serverPort}`);
1026
+ if (config.tunnel.remotePort) args.push('--tunnel-remote-port', `${config.tunnel.remotePort}`);
1027
+ }
1028
+ return args;
1029
+ }
1030
+
1031
+ async function startConnectorService(config) {
1032
+ const existing = readServiceRecord();
1033
+ if (existing?.pid && isPidRunning(existing.pid)) {
1034
+ return {
1035
+ ok: false,
1036
+ message: `Connector service is already running (PID ${existing.pid}). Run service-stop first.`,
1037
+ };
1038
+ }
1039
+
1040
+ const { baseDir, outLogPath, errLogPath } = resolveServicePaths();
1041
+ fs.mkdirSync(baseDir, { recursive: true });
1042
+ const outFd = fs.openSync(outLogPath, 'a');
1043
+ const errFd = fs.openSync(errLogPath, 'a');
1044
+ const args = buildConnectArgsFromConfig(config);
1045
+ const child = spawn(process.execPath, [CURRENT_CLI_PATH, ...args], {
1046
+ detached: true,
1047
+ windowsHide: true,
1048
+ stdio: ['ignore', outFd, errFd],
1049
+ });
1050
+ child.unref();
1051
+ fs.closeSync(outFd);
1052
+ fs.closeSync(errFd);
1053
+
1054
+ writeServiceRecord({
1055
+ pid: child.pid,
1056
+ startedAt: new Date().toISOString(),
1057
+ sessionKey: config.sessionKey,
1058
+ baseUrl: config.baseUrl,
1059
+ localBaseUrl: config.localBaseUrl,
1060
+ outLogPath,
1061
+ errLogPath,
1062
+ });
1063
+
1064
+ return {
1065
+ ok: true,
1066
+ pid: child.pid,
1067
+ outLogPath,
1068
+ errLogPath,
1069
+ };
1070
+ }
1071
+
1072
+ async function runServiceStop() {
1073
+ const record = readServiceRecord();
1074
+ if (!record?.pid) {
1075
+ console.log('[PASS] No background connector service record found.');
1076
+ return;
1077
+ }
1078
+ const pid = Number.parseInt(`${record.pid || ''}`, 10);
1079
+ if (!Number.isFinite(pid) || pid <= 0) {
1080
+ deleteServiceRecord();
1081
+ console.log('[WARN] Service PID record is invalid. Record removed.');
1082
+ return;
1083
+ }
1084
+ if (!isPidRunning(pid)) {
1085
+ deleteServiceRecord();
1086
+ console.log(`[PASS] Connector service PID ${pid} is not running. Record removed.`);
1087
+ return;
1088
+ }
1089
+ try {
1090
+ process.kill(pid);
1091
+ deleteServiceRecord();
1092
+ console.log(`[PASS] Stopped connector service (PID ${pid}).`);
1093
+ } catch (error) {
1094
+ throw new Error(`Failed to stop connector service PID ${pid}: ${error?.message || error}`);
1095
+ }
1096
+ }
1097
+
1098
+ async function runServiceStatus() {
1099
+ const record = readServiceRecord();
1100
+ if (!record?.pid) {
1101
+ console.log('[PASS] Connector service is not running (no record).');
1102
+ return;
1103
+ }
1104
+ const running = isPidRunning(record.pid);
1105
+ console.log('');
1106
+ console.log('MateClaw Connector Service Status');
1107
+ console.log('================================');
1108
+ console.log(`PID : ${record.pid}`);
1109
+ console.log(`Running : ${running ? 'yes' : 'no'}`);
1110
+ console.log(`StartedAt : ${record.startedAt || '-'}`);
1111
+ console.log(`Session : ${record.sessionKey || '-'}`);
1112
+ console.log(`Base URL : ${record.baseUrl || '-'}`);
1113
+ console.log(`Out Log : ${record.outLogPath || '-'}`);
1114
+ console.log(`Err Log : ${record.errLogPath || '-'}`);
1115
+ console.log('');
1116
+ if (!running) {
1117
+ console.log('[WARN] Service record exists but process is not running. Consider service-stop to clean record.');
1118
+ }
1119
+ }
1120
+
914
1121
  async function runInstall(options) {
915
1122
  const dryRun = optionEnabled(options, 'dry-run');
916
1123
  const skipGatewayRestart = optionEnabled(options, 'skip-gateway-restart');
@@ -928,6 +1135,35 @@ async function runInstall(options) {
928
1135
  printInstallFixSummary(fixResult, dryRun);
929
1136
 
930
1137
  const installOptions = { ...options };
1138
+ const lanOnly = optionEnabled(installOptions, 'lan-only');
1139
+ if (installOptions['chat-mode'] == null) {
1140
+ installOptions['chat-mode'] = DEFAULT_CHAT_MODE;
1141
+ }
1142
+ if (installOptions['session-key'] == null) {
1143
+ installOptions['session-key'] = DEFAULT_SESSION_KEY;
1144
+ }
1145
+ if (!lanOnly && installOptions.tunnel == null && DEFAULT_PRESET_TUNNEL_ENABLED) {
1146
+ installOptions.tunnel = 'true';
1147
+ }
1148
+ if (installOptions['tunnel-client'] == null) {
1149
+ installOptions['tunnel-client'] = DEFAULT_TUNNEL_CLIENT;
1150
+ }
1151
+ if (installOptions['tunnel-user'] == null) {
1152
+ installOptions['tunnel-user'] = DEFAULT_PRESET_TUNNEL_USER;
1153
+ }
1154
+ if (installOptions['tunnel-host'] == null) {
1155
+ installOptions['tunnel-host'] = DEFAULT_PRESET_TUNNEL_HOST;
1156
+ }
1157
+ if (installOptions['tunnel-password'] == null) {
1158
+ installOptions['tunnel-password'] = DEFAULT_PRESET_TUNNEL_PASSWORD;
1159
+ }
1160
+ if (!lanOnly && installOptions['public-base-url'] == null) {
1161
+ installOptions['public-base-url'] = DEFAULT_PRESET_PUBLIC_BASE_URL;
1162
+ }
1163
+ if (lanOnly) {
1164
+ installOptions.tunnel = 'false';
1165
+ delete installOptions['public-base-url'];
1166
+ }
931
1167
 
932
1168
  const openclawUrl = stripTrailingSlash(
933
1169
  installOptions['openclaw-url'] ||
@@ -1024,11 +1260,27 @@ async function runInstall(options) {
1024
1260
  return;
1025
1261
  }
1026
1262
 
1027
- const config = buildConfig(installOptions);
1028
- await startConnector(config);
1263
+ const config = await buildConfig(installOptions);
1264
+ const foreground = optionEnabled(installOptions, 'foreground');
1265
+ if (foreground) {
1266
+ console.log('[WARN] Running connector in foreground mode (--foreground). Keep this CMD open.');
1267
+ await startConnector(config);
1268
+ return;
1269
+ }
1270
+
1271
+ const serviceStart = await startConnectorService(config);
1272
+ if (!serviceStart.ok) {
1273
+ throw new Error(serviceStart.message || 'Failed to start background connector service.');
1274
+ }
1275
+ console.log(`[PASS] Connector service started in background (PID ${serviceStart.pid}).`);
1276
+ console.log('[PASS] You can now close this CMD window.');
1277
+ console.log(`[PASS] Service logs: ${serviceStart.outLogPath}`);
1278
+ console.log('[PASS] Stop service: npx -y mateclaw-openclaw-plugin service-stop');
1279
+ console.log('[PASS] Service status: npx -y mateclaw-openclaw-plugin service-status');
1280
+ printBanner(config);
1029
1281
  }
1030
1282
 
1031
- function buildConfig(options) {
1283
+ async function buildConfig(options) {
1032
1284
  const openclawUrl = stripTrailingSlash(
1033
1285
  options['openclaw-url'] ||
1034
1286
  process.env.OPENCLAW_BASE_URL ||
@@ -1057,6 +1309,10 @@ function buildConfig(options) {
1057
1309
  process.env.MATECLAW_CONNECTOR_LAN_HOST ||
1058
1310
  lanCandidates[0]?.address ||
1059
1311
  '';
1312
+ const lanOnly = optionEnabled(options, 'lan-only');
1313
+ const publicBaseUrl = normalizePublicBaseUrl(
1314
+ options['public-base-url'] || process.env.MATECLAW_PUBLIC_BASE_URL || '',
1315
+ );
1060
1316
  const agentId =
1061
1317
  options['agent-id'] || process.env.MATECLAW_AGENT_ID || DEFAULT_AGENT_ID;
1062
1318
  const sessionKey = normalizeSessionKey(
@@ -1085,6 +1341,22 @@ function buildConfig(options) {
1085
1341
  process.env.OPENCLAW_UPSTREAM_TOKEN ||
1086
1342
  detectLocalDeviceAuthToken() ||
1087
1343
  detectLocalGatewayToken();
1344
+ const tunnelEnabled = !lanOnly && optionEnabled(options, 'tunnel');
1345
+ const tunnelClient = normalizeTunnelClient(
1346
+ options['tunnel-client'] || process.env.MATECLAW_TUNNEL_CLIENT || '',
1347
+ );
1348
+ const tunnelBin = `${options['tunnel-bin'] || process.env.MATECLAW_TUNNEL_BIN || tunnelClient}`.trim();
1349
+ const tunnelUser = `${options['tunnel-user'] || process.env.MATECLAW_TUNNEL_USER || ''}`.trim();
1350
+ const tunnelHost = `${options['tunnel-host'] || process.env.MATECLAW_TUNNEL_HOST || ''}`.trim();
1351
+ let tunnelPassword = `${options['tunnel-password'] || process.env.MATECLAW_TUNNEL_PASSWORD || ''}`.trim();
1352
+ const tunnelServerPort = parsePortOrDefault(
1353
+ options['tunnel-server-port'] || process.env.MATECLAW_TUNNEL_SERVER_PORT || `${DEFAULT_TUNNEL_SERVER_PORT}`,
1354
+ DEFAULT_TUNNEL_SERVER_PORT,
1355
+ );
1356
+ const tunnelRemotePort = parsePortOrDefault(
1357
+ options['tunnel-remote-port'] || process.env.MATECLAW_TUNNEL_REMOTE_PORT || `${DEFAULT_TUNNEL_REMOTE_PORT}`,
1358
+ DEFAULT_TUNNEL_REMOTE_PORT,
1359
+ );
1088
1360
 
1089
1361
  if (!Number.isFinite(port) || port <= 0) {
1090
1362
  throw new Error('Invalid port.');
@@ -1100,9 +1372,29 @@ function buildConfig(options) {
1100
1372
  'Unable to detect a LAN host automatically. Pass --lan-host or set MATECLAW_CONNECTOR_LAN_HOST.',
1101
1373
  );
1102
1374
  }
1375
+ if (tunnelEnabled && (!tunnelUser || !tunnelHost)) {
1376
+ throw new Error(
1377
+ 'Tunnel enabled but tunnel target is incomplete. Pass --tunnel-user and --tunnel-host.',
1378
+ );
1379
+ }
1380
+ if (tunnelEnabled && tunnelClient !== 'builtin' && !isCommandAvailable(tunnelBin)) {
1381
+ throw new Error(
1382
+ `Tunnel client not found: ${tunnelBin}. Install it or set --tunnel-bin with a valid command.`,
1383
+ );
1384
+ }
1385
+ if (tunnelEnabled && (tunnelClient === 'nssh' || tunnelClient === 'builtin') && !tunnelPassword) {
1386
+ tunnelPassword = await readSecretFromPrompt('Tunnel password (hidden input): ');
1387
+ }
1388
+ if (tunnelEnabled && (tunnelClient === 'nssh' || tunnelClient === 'builtin') && !tunnelPassword) {
1389
+ throw new Error(
1390
+ `Tunnel client ${tunnelClient} requires --tunnel-password (or MATECLAW_TUNNEL_PASSWORD).`,
1391
+ );
1392
+ }
1103
1393
 
1104
1394
  const expiresAt = new Date(Date.now() + expiresMinutes * 60 * 1000);
1105
- const baseUrl = `http://${lanHost}:${port}`;
1395
+ const localBaseUrl = `http://${lanHost}:${port}`;
1396
+ const effectivePublicBaseUrl = tunnelEnabled ? publicBaseUrl : '';
1397
+ const baseUrl = effectivePublicBaseUrl || localBaseUrl;
1106
1398
 
1107
1399
  return {
1108
1400
  openclawUrl,
@@ -1119,8 +1411,23 @@ function buildConfig(options) {
1119
1411
  upstreamTimeoutMs,
1120
1412
  upstreamToken,
1121
1413
  lanCandidates,
1414
+ lanOnly,
1415
+ localBaseUrl,
1416
+ publicBaseUrl: effectivePublicBaseUrl,
1122
1417
  baseUrl,
1123
1418
  acceptedChatPaths,
1419
+ tunnel: {
1420
+ enabled: tunnelEnabled,
1421
+ client: tunnelClient,
1422
+ bin: tunnelBin,
1423
+ user: tunnelUser,
1424
+ host: tunnelHost,
1425
+ password: tunnelPassword,
1426
+ serverPort: tunnelServerPort,
1427
+ remotePort: tunnelRemotePort,
1428
+ localHost: DEFAULT_TUNNEL_LOCAL_HOST,
1429
+ localPort: port,
1430
+ },
1124
1431
  qrPayload: {
1125
1432
  type: PAYLOAD_TYPE,
1126
1433
  name,
@@ -1188,6 +1495,254 @@ function loadLocalDeviceIdentity() {
1188
1495
  }
1189
1496
  }
1190
1497
 
1498
+ function buildTunnelLaunchPlan(config) {
1499
+ const tunnel = config.tunnel;
1500
+ if (!tunnel?.enabled) return null;
1501
+
1502
+ const routeSpec = `${tunnel.remotePort}:${tunnel.localHost}:${tunnel.localPort}`;
1503
+ const destination = `${tunnel.user}@${tunnel.host}`;
1504
+ const command = tunnel.bin || tunnel.client;
1505
+ const args = ['-R', routeSpec, destination];
1506
+
1507
+ if (tunnel.serverPort && tunnel.serverPort !== DEFAULT_TUNNEL_SERVER_PORT) {
1508
+ args.push('-p', `${tunnel.serverPort}`);
1509
+ }
1510
+
1511
+ if (tunnel.client === 'nssh') {
1512
+ args.push('--passwd', tunnel.password);
1513
+ }
1514
+
1515
+ return {
1516
+ command,
1517
+ args,
1518
+ routeSpec,
1519
+ destination,
1520
+ };
1521
+ }
1522
+
1523
+ function resolveServicePaths() {
1524
+ const baseDir = path.join(os.homedir(), '.openclaw', 'mateclaw-openclaw-plugin');
1525
+ return {
1526
+ baseDir,
1527
+ pidFilePath: path.join(baseDir, 'connector-service.json'),
1528
+ outLogPath: path.join(baseDir, 'connector-service.out.log'),
1529
+ errLogPath: path.join(baseDir, 'connector-service.err.log'),
1530
+ };
1531
+ }
1532
+
1533
+ function readServiceRecord() {
1534
+ const { pidFilePath } = resolveServicePaths();
1535
+ const snapshot = readJsonFileSafe(pidFilePath);
1536
+ if (!snapshot.exists || !snapshot.valid || !snapshot.value || typeof snapshot.value !== 'object') {
1537
+ return null;
1538
+ }
1539
+ return snapshot.value;
1540
+ }
1541
+
1542
+ function isPidRunning(pid) {
1543
+ const numericPid = Number.parseInt(`${pid || ''}`, 10);
1544
+ if (!Number.isFinite(numericPid) || numericPid <= 0) {
1545
+ return false;
1546
+ }
1547
+ try {
1548
+ process.kill(numericPid, 0);
1549
+ return true;
1550
+ } catch {
1551
+ return false;
1552
+ }
1553
+ }
1554
+
1555
+ function writeServiceRecord(record) {
1556
+ const { baseDir, pidFilePath } = resolveServicePaths();
1557
+ fs.mkdirSync(baseDir, { recursive: true });
1558
+ writeJsonFile(pidFilePath, record);
1559
+ }
1560
+
1561
+ function deleteServiceRecord() {
1562
+ const { pidFilePath } = resolveServicePaths();
1563
+ if (fs.existsSync(pidFilePath)) {
1564
+ fs.unlinkSync(pidFilePath);
1565
+ }
1566
+ }
1567
+
1568
+ function launchExternalTunnel(plan) {
1569
+ const commandText = `${plan.command} ${plan.args.join(' ')}`;
1570
+ try {
1571
+ const child = spawn(plan.command, plan.args, {
1572
+ shell: process.platform === 'win32',
1573
+ stdio: ['ignore', 'pipe', 'pipe'],
1574
+ });
1575
+
1576
+ child.stdout?.on('data', (chunk) => {
1577
+ const text = `${chunk || ''}`.trim();
1578
+ if (text) {
1579
+ console.log(`[tunnel] ${text}`);
1580
+ }
1581
+ });
1582
+ child.stderr?.on('data', (chunk) => {
1583
+ const text = `${chunk || ''}`.trim();
1584
+ if (text) {
1585
+ console.log(`[tunnel][stderr] ${text}`);
1586
+ }
1587
+ });
1588
+ child.on('exit', (code, signal) => {
1589
+ const reason = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
1590
+ console.log(`[WARN] Tunnel process exited (${reason}).`);
1591
+ });
1592
+
1593
+ return {
1594
+ enabled: true,
1595
+ started: true,
1596
+ child,
1597
+ stop: null,
1598
+ error: '',
1599
+ commandText,
1600
+ destination: plan.destination,
1601
+ routeSpec: plan.routeSpec,
1602
+ };
1603
+ } catch (error) {
1604
+ return {
1605
+ enabled: true,
1606
+ started: false,
1607
+ child: null,
1608
+ stop: null,
1609
+ error: `${error?.message || error}`,
1610
+ commandText,
1611
+ destination: plan.destination,
1612
+ routeSpec: plan.routeSpec,
1613
+ };
1614
+ }
1615
+ }
1616
+
1617
+ async function launchBuiltinTunnel(config, plan) {
1618
+ const tunnel = config.tunnel;
1619
+ const commandText = `builtin-ssh -R ${plan.routeSpec} ${plan.destination}${
1620
+ tunnel.serverPort !== DEFAULT_TUNNEL_SERVER_PORT ? ` -p ${tunnel.serverPort}` : ''
1621
+ }`;
1622
+
1623
+ return await new Promise((resolve) => {
1624
+ const conn = new SshClient();
1625
+ let settled = false;
1626
+
1627
+ const settle = (runtime) => {
1628
+ if (settled) return;
1629
+ settled = true;
1630
+ clearTimeout(timer);
1631
+ resolve(runtime);
1632
+ };
1633
+
1634
+ const fail = (error) => {
1635
+ const detail = `${error?.message || error || 'unknown error'}`;
1636
+ try {
1637
+ conn.end();
1638
+ } catch {}
1639
+ settle({
1640
+ enabled: true,
1641
+ started: false,
1642
+ child: null,
1643
+ stop: null,
1644
+ error: detail,
1645
+ commandText,
1646
+ destination: plan.destination,
1647
+ routeSpec: plan.routeSpec,
1648
+ });
1649
+ };
1650
+
1651
+ const timer = setTimeout(() => {
1652
+ fail(new Error('builtin tunnel connection timeout'));
1653
+ }, 12000);
1654
+
1655
+ conn.on('ready', () => {
1656
+ conn.forwardIn('0.0.0.0', tunnel.remotePort, (error) => {
1657
+ if (error) {
1658
+ fail(error);
1659
+ return;
1660
+ }
1661
+
1662
+ settle({
1663
+ enabled: true,
1664
+ started: true,
1665
+ child: null,
1666
+ stop: () => {
1667
+ try {
1668
+ conn.end();
1669
+ } catch {}
1670
+ },
1671
+ error: '',
1672
+ commandText,
1673
+ destination: plan.destination,
1674
+ routeSpec: plan.routeSpec,
1675
+ });
1676
+ });
1677
+ });
1678
+
1679
+ conn.on('tcp connection', (_details, accept) => {
1680
+ const remoteStream = accept();
1681
+ const localSocket = net.connect(tunnel.localPort, tunnel.localHost);
1682
+
1683
+ localSocket.on('error', (error) => {
1684
+ console.log(`[WARN] Tunnel local socket error: ${error?.message || error}`);
1685
+ try {
1686
+ remoteStream.destroy(error);
1687
+ } catch {}
1688
+ });
1689
+ remoteStream.on('error', (error) => {
1690
+ console.log(`[WARN] Tunnel remote stream error: ${error?.message || error}`);
1691
+ try {
1692
+ localSocket.destroy(error);
1693
+ } catch {}
1694
+ });
1695
+
1696
+ remoteStream.pipe(localSocket);
1697
+ localSocket.pipe(remoteStream);
1698
+ });
1699
+
1700
+ conn.on('error', (error) => {
1701
+ if (!settled) {
1702
+ fail(error);
1703
+ return;
1704
+ }
1705
+ console.log(`[WARN] Tunnel runtime error: ${error?.message || error}`);
1706
+ });
1707
+
1708
+ conn.on('close', () => {
1709
+ if (settled) {
1710
+ console.log('[WARN] Tunnel process exited (builtin ssh closed).');
1711
+ }
1712
+ });
1713
+
1714
+ conn.connect({
1715
+ host: tunnel.host,
1716
+ port: tunnel.serverPort,
1717
+ username: tunnel.user,
1718
+ password: tunnel.password,
1719
+ readyTimeout: 12000,
1720
+ keepaliveInterval: 15000,
1721
+ keepaliveCountMax: 4,
1722
+ });
1723
+ });
1724
+ }
1725
+
1726
+ async function launchTunnel(config) {
1727
+ const plan = buildTunnelLaunchPlan(config);
1728
+ if (!plan) {
1729
+ return {
1730
+ enabled: false,
1731
+ started: false,
1732
+ child: null,
1733
+ stop: null,
1734
+ error: '',
1735
+ commandText: '',
1736
+ };
1737
+ }
1738
+
1739
+ if (config.tunnel.client === 'builtin') {
1740
+ return await launchBuiltinTunnel(config, plan);
1741
+ }
1742
+
1743
+ return launchExternalTunnel(plan);
1744
+ }
1745
+
1191
1746
  async function startConnector(config) {
1192
1747
  const server = http.createServer((req, res) => {
1193
1748
  handleRequest(req, res, config).catch((error) => {
@@ -1203,11 +1758,22 @@ async function startConnector(config) {
1203
1758
  server.listen(config.port, config.listenHost, resolve);
1204
1759
  });
1205
1760
 
1761
+ const tunnelRuntime = await launchTunnel(config);
1762
+ config.tunnelRuntime = tunnelRuntime;
1763
+
1206
1764
  printBanner(config);
1207
1765
 
1208
1766
  const shutdown = () => {
1209
- console.log('\nShutting down Local OpenClaw Demo Connector...');
1210
- server.close(() => process.exit(0));
1767
+ console.log('\nShutting down MateClaw OpenClaw Connector...');
1768
+ const done = () => server.close(() => process.exit(0));
1769
+ if (typeof tunnelRuntime?.stop === 'function') {
1770
+ console.log('Stopping tunnel process...');
1771
+ tunnelRuntime.stop();
1772
+ } else if (tunnelRuntime?.child && !tunnelRuntime.child.killed) {
1773
+ console.log('Stopping tunnel process...');
1774
+ tunnelRuntime.child.kill();
1775
+ }
1776
+ done();
1211
1777
  };
1212
1778
 
1213
1779
  process.on('SIGINT', shutdown);
@@ -1227,7 +1793,7 @@ async function handleRequest(req, res, config) {
1227
1793
  if (requestUrl.pathname === '/health' && req.method === 'GET') {
1228
1794
  sendJson(res, 200, {
1229
1795
  ok: true,
1230
- mode: 'mateclaw_local_openclaw_demo',
1796
+ mode: PAYLOAD_TYPE,
1231
1797
  chatMode: config.chatMode,
1232
1798
  sessionId: config.sessionKey,
1233
1799
  upstream: config.openclawUrl,
@@ -2058,6 +2624,30 @@ function stripTrailingSlash(value) {
2058
2624
  return value.replace(/\/+$/, '');
2059
2625
  }
2060
2626
 
2627
+ function normalizePublicBaseUrl(value) {
2628
+ const raw = `${value || ''}`.trim();
2629
+ if (!raw) return '';
2630
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
2631
+ try {
2632
+ const parsed = new URL(withScheme);
2633
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
2634
+ throw new Error('Public base URL must start with http:// or https://');
2635
+ }
2636
+ return stripTrailingSlash(parsed.toString());
2637
+ } catch {
2638
+ throw new Error(`Invalid public-base-url: ${value}`);
2639
+ }
2640
+ }
2641
+
2642
+ function normalizeTunnelClient(value) {
2643
+ const normalized = `${value || ''}`.trim().toLowerCase();
2644
+ if (!normalized) return DEFAULT_TUNNEL_CLIENT;
2645
+ if (normalized === 'builtin' || normalized === 'ssh' || normalized === 'nssh') {
2646
+ return normalized;
2647
+ }
2648
+ throw new Error(`Invalid tunnel-client: ${value}. Use builtin, ssh or nssh.`);
2649
+ }
2650
+
2061
2651
  function normalizePath(value) {
2062
2652
  if (!value) {
2063
2653
  return DEFAULT_CHAT_PATH;
@@ -2235,8 +2825,9 @@ function isSpecialUseIpv4(address) {
2235
2825
  }
2236
2826
 
2237
2827
  function printBanner(config) {
2828
+ const tunnelRuntime = config.tunnelRuntime || {};
2238
2829
  console.log('');
2239
- console.log('MateClaw Local OpenClaw Demo Connector');
2830
+ console.log('MateClaw OpenClaw Connector');
2240
2831
  console.log('======================================');
2241
2832
  console.log(`Upstream OpenClaw : ${config.openclawUrl}`);
2242
2833
  console.log(`Chat Mode : ${config.chatMode}`);
@@ -2244,9 +2835,10 @@ function printBanner(config) {
2244
2835
  console.log(`Chat Path : ${config.chatPath}`);
2245
2836
  console.log(`Accepted Paths : ${config.acceptedChatPaths.join(', ')}`);
2246
2837
  console.log(`Desktop Bind Host : ${config.listenHost}:${config.port}`);
2838
+ console.log(`Local Access URL : ${config.localBaseUrl}`);
2247
2839
  console.log(`Mobile Access URL : ${config.baseUrl}`);
2248
2840
  console.log(`Agent ID : ${config.agentId}`);
2249
- console.log(`Token : ${config.token}`);
2841
+ console.log(`Token : ${maskToken(config.token)}`);
2250
2842
  console.log(`Expires At : ${config.expiresAt.toISOString()}`);
2251
2843
  console.log(`Upstream Timeout : ${config.upstreamTimeoutMs}ms`);
2252
2844
  console.log(
@@ -2260,13 +2852,36 @@ function printBanner(config) {
2260
2852
  console.log(` - ${item.address} (${item.iface}, score=${item.score})`);
2261
2853
  }
2262
2854
  }
2855
+ if (config.tunnel?.enabled) {
2856
+ console.log('Tunnel : enabled');
2857
+ console.log(`Tunnel Client : ${config.tunnel.client}`);
2858
+ if (tunnelRuntime.started) {
2859
+ console.log(
2860
+ `Tunnel Route : ${config.tunnel.remotePort} -> ${config.tunnel.localHost}:${config.tunnel.localPort} (${config.tunnel.user}@${config.tunnel.host})`,
2861
+ );
2862
+ console.log(`Tunnel Command : ${maskTunnelPasswordInCommand(tunnelRuntime.commandText)}`);
2863
+ } else {
2864
+ console.log(`Tunnel Status : failed to start (${tunnelRuntime.error || 'unknown error'})`);
2865
+ if (tunnelRuntime.commandText) {
2866
+ console.log(`Tunnel Command : ${maskTunnelPasswordInCommand(tunnelRuntime.commandText)}`);
2867
+ }
2868
+ }
2869
+ if (!config.publicBaseUrl) {
2870
+ console.log('[WARN] Tunnel is enabled but --public-base-url is missing. QR still points to LAN URL.');
2871
+ }
2872
+ } else if (config.lanOnly) {
2873
+ console.log('Tunnel : disabled (LAN-only mode)');
2874
+ }
2263
2875
  console.log('');
2264
- console.log('FoloToy-style bind flow: install once -> scan the QR shown here -> chat.');
2876
+ console.log('One-command bind flow: install once -> scan the QR shown here -> chat.');
2265
2877
  console.log('If binding fails, pin the LAN IP manually:');
2266
2878
  console.log(
2267
2879
  ` node ./src/cli.mjs install --lan-host ${config.lanHost || '<your-lan-ip>'} --port ${config.port}`,
2268
2880
  );
2269
- console.log(`Quick health URL: ${config.baseUrl}/health`);
2881
+ console.log(`Quick health URL (local): ${config.localBaseUrl}/health`);
2882
+ if (config.publicBaseUrl) {
2883
+ console.log(`Public bind URL: ${config.publicBaseUrl}`);
2884
+ }
2270
2885
  console.log('');
2271
2886
  console.log('Scan this QR code in the MateClaw app (this is the OpenClaw-side bind QR):');
2272
2887
  console.log('');