openclaw-node-harness 2.0.1 → 2.0.2

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.
@@ -43,6 +43,21 @@ const PROVIDER = getArg('--provider', 'claude');
43
43
  const EXPIRES = getArg('--expires', '48h');
44
44
  const REPO = getArg('--repo', 'https://github.com/moltyguibros-design/openclaw-node.git');
45
45
  const ONE_LINER = args.includes('--one-liner');
46
+ const NO_SSH = args.includes('--no-ssh');
47
+
48
+ // Read lead node's SSH public key (auto-discover from ~/.ssh/)
49
+ function getLeadSSHPubkey() {
50
+ if (NO_SSH) return null;
51
+ const sshDir = path.join(os.homedir(), '.ssh');
52
+ const candidates = ['id_ed25519_openclaw.pub', 'id_ed25519.pub', 'id_rsa.pub', 'id_ecdsa.pub'];
53
+ for (const f of candidates) {
54
+ const p = path.join(sshDir, f);
55
+ if (fs.existsSync(p)) {
56
+ return fs.readFileSync(p, 'utf8').trim();
57
+ }
58
+ }
59
+ return null;
60
+ }
46
61
 
47
62
  // ── Token secret ──────────────────────────────────────
48
63
  // Stored at ~/.openclaw/.mesh-secret. Created on first use.
@@ -81,8 +96,10 @@ function generateToken() {
81
96
  const secret = getOrCreateSecret();
82
97
  const expiresAt = parseExpiry(EXPIRES);
83
98
 
99
+ const sshPubkey = getLeadSSHPubkey();
100
+
84
101
  const payload = {
85
- v: 2, // token version (v2: added repo field)
102
+ v: 3, // token version (v3: added ssh_pubkey)
86
103
  nats: NATS_URL, // NATS server URL
87
104
  role: ROLE, // node role
88
105
  provider: PROVIDER, // default LLM provider
@@ -90,6 +107,7 @@ function generateToken() {
90
107
  lead: os.hostname(), // lead node hostname (for reference)
91
108
  issued: Date.now(), // issued timestamp
92
109
  expires: expiresAt, // expiry timestamp
110
+ ...(sshPubkey && { ssh_pubkey: sshPubkey }), // lead node's SSH public key
93
111
  };
94
112
 
95
113
  // HMAC-SHA256 signature for integrity
@@ -1,32 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * openclaw-node-init.js — One-click mesh node provisioner.
4
+ * openclaw-node-init.js — Zero-config mesh node provisioner.
5
5
  *
6
- * Takes a join token (from mesh-join-token.js) and bootstraps a fully
7
- * functional mesh worker node. Handles:
8
- * 1. Token validation (signature + expiry)
6
+ * Tailscale IS the trust layer. No tokens. No secrets. No lead interaction.
7
+ *
8
+ * What it does:
9
+ * 1. Scan Tailscale peers for NATS (port 4222)
9
10
  * 2. OS detection (macOS/Linux)
10
- * 3. Dependency checks (Node.js, git, nats-server optional)
11
+ * 3. Dependency checks (Node.js, git)
11
12
  * 4. Directory structure (~/.openclaw/)
12
- * 5. NATS configuration (from token)
13
- * 6. Mesh code installation (repo URL from token)
13
+ * 5. NATS configuration (auto-discovered)
14
+ * 6. Mesh code installation (git clone)
14
15
  * 7. Service installation (launchd/systemd)
15
16
  * 8. Health verification (service alive + NATS connectivity)
16
17
  *
17
18
  * Usage:
18
- * MESH_JOIN_TOKEN=<token> node bin/openclaw-node-init.js
19
- * node bin/openclaw-node-init.js --token <token>
20
- * node bin/openclaw-node-init.js --token <token> --dry-run
21
- * node bin/openclaw-node-init.js --token <token> --provider deepseek
22
- *
23
- * The token contains NATS URL, repo URL, role, and default provider.
24
- * API keys must be set separately in ~/.openclaw/openclaw.env after provisioning.
19
+ * npx openclaw-node # auto-discover everything
20
+ * node bin/openclaw-node-init.js # same
21
+ * node bin/openclaw-node-init.js --nats nats://x:4222 # explicit NATS URL
22
+ * node bin/openclaw-node-init.js --provider deepseek # set default LLM
23
+ * node bin/openclaw-node-init.js --dry-run # preview only
25
24
  */
26
25
 
27
26
  const { execSync, spawnSync } = require('child_process');
28
- const crypto = require('crypto');
29
27
  const fs = require('fs');
28
+ const net = require('net');
30
29
  const os = require('os');
31
30
  const path = require('path');
32
31
 
@@ -40,11 +39,10 @@ function getArg(flag, defaultVal) {
40
39
  return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
41
40
  }
42
41
 
43
- const TOKEN_RAW = getArg('--token', null) || process.env.MESH_JOIN_TOKEN;
44
- const PROVIDER_OVERRIDE = getArg('--provider', null);
45
-
46
- // Default repo URL — used when token is v1 (no repo field)
47
- const DEFAULT_REPO = 'https://github.com/moltyguibros-design/openclaw-node.git';
42
+ const NATS_OVERRIDE = getArg('--nats', null);
43
+ const PROVIDER_OVERRIDE = getArg('--provider', 'claude');
44
+ const REPO = getArg('--repo', 'https://github.com/moltyguibros-design/openclaw-node.git');
45
+ const SSH_PUBKEY = getArg('--ssh-key', null);
48
46
 
49
47
  // ── Logging ───────────────────────────────────────────
50
48
 
@@ -61,39 +59,91 @@ function warn(msg){ console.log(`${YELLOW} ⚠${RESET} ${msg}`); }
61
59
  function fail(msg){ console.error(`${RED} ✗${RESET} ${msg}`); }
62
60
  function step(n, msg) { console.log(`\n${BOLD}[${n}]${RESET} ${msg}`); }
63
61
 
64
- // ── Token Parsing ─────────────────────────────────────
62
+ // ── TCP Port Probe (pure Node, no nc dependency) ────
63
+
64
+ function probePort(ip, port) {
65
+ return new Promise((resolve) => {
66
+ const sock = new net.Socket();
67
+ sock.setTimeout(2000);
68
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
69
+ sock.on('error', () => resolve(false));
70
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
71
+ sock.connect(port, ip);
72
+ });
73
+ }
65
74
 
66
- function parseToken(raw) {
67
- if (!raw) {
68
- fail('No join token provided. Use --token <token> or set MESH_JOIN_TOKEN env var.');
69
- fail('Generate a token on the lead node: node bin/mesh-join-token.js');
70
- process.exit(1);
75
+ // ── Tailscale NATS Discovery ─────────────────────────
76
+
77
+ async function discoverNats() {
78
+ if (NATS_OVERRIDE) {
79
+ ok(`Using explicit NATS URL: ${NATS_OVERRIDE}`);
80
+ return NATS_OVERRIDE;
71
81
  }
72
82
 
83
+ // Check existing config first
84
+ const envPath = path.join(os.homedir(), '.openclaw', 'openclaw.env');
85
+ if (fs.existsSync(envPath)) {
86
+ const content = fs.readFileSync(envPath, 'utf8');
87
+ const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
88
+ if (match) {
89
+ const existing = match[1].trim();
90
+ ok(`Found existing NATS config: ${existing}`);
91
+ return existing;
92
+ }
93
+ }
94
+
95
+ // Scan Tailscale peers
96
+ log('Scanning Tailscale network for NATS...');
97
+
98
+ let tsStatus;
73
99
  try {
74
- const decoded = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
75
- return { payload: decoded.p, signature: decoded.s };
100
+ tsStatus = JSON.parse(execSync('tailscale status --json', { encoding: 'utf8', timeout: 10000 }));
76
101
  } catch (e) {
77
- fail(`Invalid token format: ${e.message}`);
102
+ fail('Tailscale not available or not connected.');
103
+ fail('Install Tailscale and join your network, or use --nats nats://host:4222');
78
104
  process.exit(1);
79
105
  }
80
- }
81
106
 
82
- function validateToken(payload) {
83
- // Accept v1 (no repo) and v2 (with repo)
84
- if (payload.v !== 1 && payload.v !== 2) {
85
- fail(`Unsupported token version: ${payload.v}. This provisioner supports v1 and v2.`);
86
- process.exit(1);
107
+ // Collect all peer IPs (including self)
108
+ const candidates = [];
109
+
110
+ // Add self
111
+ if (tsStatus.Self && tsStatus.Self.TailscaleIPs) {
112
+ for (const ip of tsStatus.Self.TailscaleIPs) {
113
+ if (ip.includes('.')) candidates.push(ip); // IPv4 only
114
+ }
87
115
  }
88
- if (payload.expires && Date.now() > payload.expires) {
89
- fail(`Token expired at ${new Date(payload.expires).toISOString()}`);
90
- fail('Generate a new token on the lead node: node bin/mesh-join-token.js');
91
- process.exit(1);
116
+
117
+ // Add peers
118
+ if (tsStatus.Peer) {
119
+ for (const [, peer] of Object.entries(tsStatus.Peer)) {
120
+ if (peer.TailscaleIPs) {
121
+ for (const ip of peer.TailscaleIPs) {
122
+ if (ip.includes('.')) candidates.push(ip); // IPv4 only
123
+ }
124
+ }
125
+ }
92
126
  }
93
- if (!payload.nats) {
94
- fail('Token missing NATS URL');
127
+
128
+ if (candidates.length === 0) {
129
+ fail('No Tailscale peers found. Is Tailscale connected?');
130
+ fail('Run: tailscale up');
95
131
  process.exit(1);
96
132
  }
133
+
134
+ log(`Found ${candidates.length} Tailscale IPs. Probing port 4222...`);
135
+
136
+ for (const ip of candidates) {
137
+ if (await probePort(ip, 4222)) {
138
+ const natsUrl = `nats://${ip}:4222`;
139
+ ok(`NATS found at ${natsUrl}`);
140
+ return natsUrl;
141
+ }
142
+ }
143
+
144
+ fail('No NATS server found on any Tailscale peer (port 4222).');
145
+ fail('Ensure NATS is running on your lead node, or use --nats nats://host:4222');
146
+ process.exit(1);
97
147
  }
98
148
 
99
149
  // ── OS Detection ──────────────────────────────────────
@@ -130,11 +180,10 @@ function getNodeVersion() {
130
180
  }
131
181
  }
132
182
 
133
- function checkDependencies(osInfo) {
183
+ function checkDependencies() {
134
184
  const deps = [];
135
185
  const missing = [];
136
186
 
137
- // Node.js 18+
138
187
  const nodeInfo = getNodeVersion();
139
188
  if (nodeInfo && nodeInfo.major >= 18) {
140
189
  deps.push({ name: 'Node.js', status: 'ok', detail: nodeInfo.version });
@@ -146,7 +195,6 @@ function checkDependencies(osInfo) {
146
195
  missing.push('node');
147
196
  }
148
197
 
149
- // Git
150
198
  if (checkCommand('git')) {
151
199
  const v = execSync('git --version', { encoding: 'utf8' }).trim();
152
200
  deps.push({ name: 'Git', status: 'ok', detail: v });
@@ -155,11 +203,11 @@ function checkDependencies(osInfo) {
155
203
  missing.push('git');
156
204
  }
157
205
 
158
- // Tailscale (optional but recommended)
159
206
  if (checkCommand('tailscale')) {
160
207
  deps.push({ name: 'Tailscale', status: 'ok', detail: 'installed' });
161
208
  } else {
162
- deps.push({ name: 'Tailscale', status: 'optional', detail: 'not installed (recommended for secure mesh)' });
209
+ deps.push({ name: 'Tailscale', status: 'missing', detail: 'required for mesh discovery' });
210
+ if (!NATS_OVERRIDE) missing.push('tailscale');
163
211
  }
164
212
 
165
213
  return { deps, missing };
@@ -186,7 +234,6 @@ function installMissing(missing, osInfo) {
186
234
  spawnSync('brew', ['install', pkg], { stdio: 'inherit' });
187
235
  }
188
236
  } else {
189
- // Linux — try apt, then yum, then dnf
190
237
  const pm = checkCommand('apt-get') ? 'apt-get'
191
238
  : checkCommand('yum') ? 'yum'
192
239
  : checkCommand('dnf') ? 'dnf'
@@ -199,7 +246,6 @@ function installMissing(missing, osInfo) {
199
246
 
200
247
  for (const dep of missing) {
201
248
  if (dep === 'node') {
202
- // Use NodeSource for recent Node.js
203
249
  log(' Installing Node.js 22.x via NodeSource...');
204
250
  try {
205
251
  execSync('curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', { stdio: 'inherit' });
@@ -208,6 +254,15 @@ function installMissing(missing, osInfo) {
208
254
  fail(`Node.js installation failed: ${e.message}`);
209
255
  process.exit(1);
210
256
  }
257
+ } else if (dep === 'tailscale') {
258
+ log(' Installing Tailscale...');
259
+ try {
260
+ execSync('curl -fsSL https://tailscale.com/install.sh | sh', { stdio: 'inherit' });
261
+ } catch (e) {
262
+ fail(`Tailscale installation failed: ${e.message}`);
263
+ fail('Install manually: https://tailscale.com/download');
264
+ process.exit(1);
265
+ }
211
266
  } else {
212
267
  log(` sudo ${pm} install -y ${dep}`);
213
268
  spawnSync('sudo', [pm, 'install', '-y', dep], { stdio: 'inherit' });
@@ -243,6 +298,46 @@ function setupDirectories() {
243
298
  }
244
299
  }
245
300
 
301
+ // ── SSH Key Provisioning ─────────────────────────────
302
+
303
+ function provisionSSHKey(pubkey) {
304
+ if (!pubkey) return;
305
+
306
+ const sshDir = path.join(os.homedir(), '.ssh');
307
+ const authKeysPath = path.join(sshDir, 'authorized_keys');
308
+
309
+ if (DRY_RUN) {
310
+ warn(`[DRY RUN] Would add SSH key to ${authKeysPath}`);
311
+ return;
312
+ }
313
+
314
+ if (!fs.existsSync(sshDir)) {
315
+ fs.mkdirSync(sshDir, { mode: 0o700 });
316
+ ok(`Created ${sshDir}`);
317
+ } else {
318
+ try { fs.chmodSync(sshDir, 0o700); } catch { /* best effort */ }
319
+ }
320
+
321
+ let existing = '';
322
+ if (fs.existsSync(authKeysPath)) {
323
+ existing = fs.readFileSync(authKeysPath, 'utf8');
324
+ const keyParts = pubkey.trim().split(/\s+/);
325
+ const keyFingerprint = keyParts.length >= 2 ? `${keyParts[0]} ${keyParts[1]}` : pubkey.trim();
326
+ if (existing.includes(keyFingerprint)) {
327
+ ok('SSH key already authorized');
328
+ return;
329
+ }
330
+ }
331
+
332
+ const entry = existing.endsWith('\n') || existing === ''
333
+ ? `${pubkey.trim()}\n`
334
+ : `\n${pubkey.trim()}\n`;
335
+ fs.appendFileSync(authKeysPath, entry, { mode: 0o600 });
336
+ try { fs.chmodSync(authKeysPath, 0o600); } catch { /* best effort */ }
337
+
338
+ ok('SSH key added to authorized_keys');
339
+ }
340
+
246
341
  // ── NATS Configuration ───────────────────────────────
247
342
 
248
343
  function configureNats(natsUrl) {
@@ -256,7 +351,6 @@ function configureNats(natsUrl) {
256
351
  let content = '';
257
352
  if (fs.existsSync(envPath)) {
258
353
  content = fs.readFileSync(envPath, 'utf8');
259
- // Update existing OPENCLAW_NATS line or append
260
354
  if (content.match(/^\s*OPENCLAW_NATS\s*=/m)) {
261
355
  content = content.replace(/^\s*OPENCLAW_NATS\s*=.*/m, `OPENCLAW_NATS=${natsUrl}`);
262
356
  } else {
@@ -276,12 +370,12 @@ function installMeshCode(repoUrl) {
276
370
  const meshDir = path.join(os.homedir(), 'openclaw');
277
371
 
278
372
  if (fs.existsSync(path.join(meshDir, 'package.json'))) {
279
- ok(`Mesh code already installed at ${meshDir}`);
280
- // npm install to ensure deps are current
373
+ ok(`Mesh code exists at ${meshDir}`);
281
374
  if (!DRY_RUN) {
282
- log(' Updating dependencies...');
375
+ log(' Pulling latest + installing deps...');
376
+ spawnSync('git', ['pull', '--ff-only'], { cwd: meshDir, stdio: 'pipe' });
283
377
  spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
284
- ok('Dependencies updated');
378
+ ok('Updated');
285
379
  }
286
380
  return meshDir;
287
381
  }
@@ -293,15 +387,11 @@ function installMeshCode(repoUrl) {
293
387
 
294
388
  log(`Cloning mesh code from ${repoUrl}...`);
295
389
  try {
296
- execSync(
297
- `git clone "${repoUrl}" "${meshDir}"`,
298
- { stdio: 'inherit', timeout: 60000 }
299
- );
390
+ execSync(`git clone "${repoUrl}" "${meshDir}"`, { stdio: 'inherit', timeout: 60000 });
300
391
  spawnSync('npm', ['install', '--production'], { cwd: meshDir, stdio: 'pipe' });
301
392
  ok('Mesh code installed');
302
393
  } catch (e) {
303
394
  fail(`Failed to clone mesh code: ${e.message}`);
304
- fail(`Repo URL: ${repoUrl}`);
305
395
  process.exit(1);
306
396
  }
307
397
 
@@ -312,7 +402,7 @@ function installMeshCode(repoUrl) {
312
402
 
313
403
  function installService(osInfo, meshDir, config) {
314
404
  const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
315
- const nodeBin = process.execPath; // path to current node binary
405
+ const nodeBin = process.execPath;
316
406
  const provider = config.provider;
317
407
 
318
408
  if (osInfo.serviceType === 'launchd') {
@@ -374,7 +464,6 @@ function installLaunchdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
374
464
  fs.writeFileSync(plistPath, plist);
375
465
  ok(`Launchd service written: ${plistPath}`);
376
466
 
377
- // Load the service
378
467
  try {
379
468
  execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
380
469
  execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
@@ -403,6 +492,7 @@ Environment=MESH_NODE_ID=${nodeId}
403
492
  Environment=MESH_LLM_PROVIDER=${provider}
404
493
  Environment=MESH_WORKSPACE=${os.homedir()}/.openclaw/workspace
405
494
  Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
495
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${os.homedir()}/.local/bin:${os.homedir()}/.npm-global/bin
406
496
  WorkingDirectory=${meshDir}
407
497
 
408
498
  [Install]
@@ -418,7 +508,6 @@ WantedBy=default.target
418
508
  fs.writeFileSync(servicePath, service);
419
509
  ok(`Systemd service written: ${servicePath}`);
420
510
 
421
- // Enable and start
422
511
  try {
423
512
  execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
424
513
  execSync('systemctl --user enable openclaw-mesh-agent', { stdio: 'pipe' });
@@ -429,22 +518,17 @@ WantedBy=default.target
429
518
  warn('Try manually: systemctl --user start openclaw-mesh-agent');
430
519
  }
431
520
 
432
- // Enable lingering — requires either sudo or polkit permission.
433
- // Without linger, the service dies when the user logs out.
434
521
  const username = os.userInfo().username;
435
522
  try {
436
- // Try without sudo first (works if polkit allows it)
437
523
  execSync(`loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
438
524
  ok(`Linger enabled for ${username} (service survives logout)`);
439
525
  } catch {
440
526
  try {
441
- // Try with sudo
442
527
  execSync(`sudo loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
443
- ok(`Linger enabled for ${username} via sudo (service survives logout)`);
444
- } catch (e2) {
445
- warn(`Could not enable linger for ${username}: ${e2.message}`);
446
- warn('Without linger, the mesh-agent service will stop when you log out.');
447
- warn(`Fix manually: sudo loginctl enable-linger ${username}`);
528
+ ok(`Linger enabled for ${username} via sudo`);
529
+ } catch {
530
+ warn(`Could not enable linger. Service stops on logout.`);
531
+ warn(`Fix: sudo loginctl enable-linger ${username}`);
448
532
  }
449
533
  }
450
534
  }
@@ -458,19 +542,15 @@ function verifyServiceRunning(osInfo) {
458
542
  }
459
543
 
460
544
  log('Waiting 8s for service to stabilize...');
461
-
462
- // Wait for the service to either stabilize or crash
463
- const waitMs = 8000;
464
545
  const start = Date.now();
465
- while (Date.now() - start < waitMs) {
466
- spawnSync('sleep', ['1']); // cross-platform 1s sleep
546
+ while (Date.now() - start < 8000) {
547
+ spawnSync('sleep', ['1']);
467
548
  }
468
549
 
469
550
  if (osInfo.serviceType === 'launchd') {
470
551
  try {
471
552
  const out = execSync('launchctl list | grep mesh-agent', { encoding: 'utf8', stdio: 'pipe' }).trim();
472
553
  if (out) {
473
- // launchctl list format: PID\tStatus\tLabel
474
554
  const parts = out.split(/\s+/);
475
555
  const pid = parts[0];
476
556
  const exitStatus = parts[1];
@@ -488,7 +568,6 @@ function verifyServiceRunning(osInfo) {
488
568
  return false;
489
569
  }
490
570
  } else {
491
- // systemd
492
571
  try {
493
572
  const result = spawnSync('systemctl', ['--user', 'is-active', 'openclaw-mesh-agent'], { encoding: 'utf8', stdio: 'pipe' });
494
573
  const status = (result.stdout || '').trim();
@@ -519,13 +598,11 @@ async function verifyNatsHealth(natsUrl, nodeId) {
519
598
  }
520
599
 
521
600
  try {
522
- // Dynamic require — nats module should be installed by now
523
601
  const nats = require('nats');
524
602
  const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
525
603
 
526
604
  ok(`NATS connected: ${nc.getServer()}`);
527
605
 
528
- // Publish a health announcement
529
606
  const sc = nats.StringCodec();
530
607
  nc.publish(`mesh.health.${nodeId}`, sc.encode(JSON.stringify({
531
608
  node_id: nodeId,
@@ -535,27 +612,20 @@ async function verifyNatsHealth(natsUrl, nodeId) {
535
612
  arch: os.arch(),
536
613
  timestamp: new Date().toISOString(),
537
614
  })));
538
-
539
615
  ok('Health announcement published');
540
616
 
541
- // Try to reach the task daemon
542
617
  try {
543
618
  const msg = await nc.request('mesh.tasks.list', sc.encode(JSON.stringify({ status: 'queued' })), { timeout: 5000 });
544
619
  const resp = JSON.parse(sc.decode(msg.data));
545
- if (resp.ok) {
546
- ok(`Task daemon reachable — ${resp.data.length} queued task(s)`);
547
- } else {
548
- warn(`Task daemon responded with error: ${resp.error}`);
549
- }
620
+ if (resp.ok) ok(`Task daemon reachable — ${resp.data.length} queued task(s)`);
550
621
  } catch {
551
- warn('Task daemon not reachable (may not be running yet — this is OK for worker-only nodes)');
622
+ warn('Task daemon not reachable (OK for worker-only nodes)');
552
623
  }
553
624
 
554
625
  await nc.drain();
555
626
  return true;
556
627
  } catch (e) {
557
628
  fail(`NATS connection failed: ${e.message}`);
558
- fail('Check that the NATS server is running and the node has network access');
559
629
  return false;
560
630
  }
561
631
  }
@@ -564,44 +634,22 @@ async function verifyNatsHealth(natsUrl, nodeId) {
564
634
 
565
635
  async function main() {
566
636
  console.log(`\n${BOLD}${CYAN}╔══════════════════════════════════════╗${RESET}`);
567
- console.log(`${BOLD}${CYAN}║ OpenClaw Mesh Node Provisioner ║${RESET}`);
637
+ console.log(`${BOLD}${CYAN}║ OpenClaw Mesh Join Network ║${RESET}`);
568
638
  console.log(`${BOLD}${CYAN}╚══════════════════════════════════════╝${RESET}\n`);
569
639
 
570
640
  if (DRY_RUN) warn('DRY RUN MODE — no changes will be made\n');
571
641
 
572
- // ── Step 1: Parse and validate token ──
573
- step(1, 'Validating join token...');
574
- const { payload } = parseToken(TOKEN_RAW);
575
- validateToken(payload);
576
- ok(`Token valid (v${payload.v}, role: ${payload.role}, provider: ${payload.provider}, lead: ${payload.lead})`);
577
- ok(`Expires: ${new Date(payload.expires).toISOString()}`);
578
-
579
- // Extract config from token (with defaults for v1 tokens)
580
- const repoUrl = payload.repo || DEFAULT_REPO;
581
- const config = {
582
- nats: payload.nats,
583
- role: payload.role,
584
- provider: PROVIDER_OVERRIDE || payload.provider,
585
- repo: repoUrl,
586
- };
587
-
588
- if (payload.repo) {
589
- ok(`Repo: ${repoUrl}`);
590
- } else {
591
- warn(`Token v1 (no repo field) — using default: ${DEFAULT_REPO}`);
592
- }
593
-
594
- // ── Step 2: Detect OS ──
595
- step(2, 'Detecting environment...');
642
+ // ── Step 1: Detect OS ──
643
+ step(1, 'Detecting environment...');
596
644
  const osInfo = detectOS();
597
645
  const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
598
646
  ok(`OS: ${osInfo.os} (${osInfo.arch})`);
599
647
  ok(`Node ID: ${nodeId}`);
600
648
  ok(`Service type: ${osInfo.serviceType}`);
601
649
 
602
- // ── Step 3: Check dependencies ──
603
- step(3, 'Checking dependencies...');
604
- const { deps, missing } = checkDependencies(osInfo);
650
+ // ── Step 2: Check & install dependencies (including Tailscale) ──
651
+ step(2, 'Checking dependencies...');
652
+ const { deps, missing } = checkDependencies();
605
653
  for (const d of deps) {
606
654
  if (d.status === 'ok') ok(`${d.name}: ${d.detail}`);
607
655
  else if (d.status === 'optional') warn(`${d.name}: ${d.detail}`);
@@ -609,14 +657,30 @@ async function main() {
609
657
  }
610
658
 
611
659
  if (missing.length > 0) {
612
- step('3b', 'Installing missing dependencies...');
660
+ step('2b', 'Installing missing dependencies...');
613
661
  installMissing(missing, osInfo);
614
662
  }
615
663
 
664
+ // ── Step 3: Discover NATS via Tailscale ──
665
+ step(3, 'Discovering NATS server...');
666
+ const natsUrl = await discoverNats();
667
+
668
+ const config = {
669
+ nats: natsUrl,
670
+ provider: PROVIDER_OVERRIDE,
671
+ repo: REPO,
672
+ };
673
+
616
674
  // ── Step 4: Create directory structure ──
617
675
  step(4, 'Setting up directories...');
618
676
  setupDirectories();
619
677
 
678
+ // ── Step 4b: Provision SSH key (if provided) ──
679
+ if (SSH_PUBKEY) {
680
+ step('4b', 'Provisioning SSH key...');
681
+ provisionSSHKey(SSH_PUBKEY);
682
+ }
683
+
620
684
  // ── Step 5: Configure NATS ──
621
685
  step(5, 'Configuring NATS connection...');
622
686
  configureNats(config.nats);
@@ -629,7 +693,7 @@ async function main() {
629
693
  step(7, `Installing ${osInfo.serviceType} service...`);
630
694
  installService(osInfo, meshDir, config);
631
695
 
632
- // ── Step 8: Verify health (service + NATS) ──
696
+ // ── Step 8: Verify health ──
633
697
  step(8, 'Verifying health...');
634
698
  const serviceAlive = verifyServiceRunning(osInfo);
635
699
  const natsHealthy = await verifyNatsHealth(config.nats, nodeId);
@@ -646,8 +710,7 @@ async function main() {
646
710
  console.log(`${BOLD}${RED} Service failed to start.${RESET}`);
647
711
  console.log(`${RED} Provisioning complete but agent is not running.${RESET}`);
648
712
  } else {
649
- console.log(`${BOLD}${YELLOW} Node provisioned but health check failed.${RESET}`);
650
- console.log(`${YELLOW} The service is installed and will retry automatically.${RESET}`);
713
+ console.log(`${BOLD}${YELLOW} Provisioned with warnings.${RESET}`);
651
714
  }
652
715
  console.log(`${BOLD}${GREEN}═══════════════════════════════════════${RESET}\n`);
653
716
 
@@ -661,10 +724,6 @@ async function main() {
661
724
  console.log(` 2. Check service: launchctl list | grep mesh-agent`);
662
725
  console.log(` 3. View logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.log`);
663
726
  }
664
- console.log(` 4. Submit a test task from lead: node bin/mesh.js submit --title "hello" --provider shell`);
665
- if (!serviceAlive) {
666
- console.log(`\n ${RED}IMPORTANT: Fix the service error above before proceeding.${RESET}`);
667
- }
668
727
  console.log('');
669
728
  }
670
729
 
package/cli.js CHANGED
@@ -3,10 +3,8 @@
3
3
  /**
4
4
  * openclaw-node CLI — entry point for `npx openclaw-node-harness`
5
5
  *
6
- * Flow:
7
- * 1. Resolve install.sh relative to this package
8
- * 2. Spawn install.sh with forwarded CLI args
9
- * 3. Forward exit code
6
+ * Spawns bin/openclaw-node-init.js directly (no shell wrapper).
7
+ * Forwards CLI args and exit code.
10
8
  */
11
9
 
12
10
  const { spawnSync } = require('child_process');
@@ -15,19 +13,19 @@ const fs = require('fs');
15
13
 
16
14
  // ── Package paths ──
17
15
  const PKG_ROOT = __dirname;
18
- const INSTALL_SCRIPT = path.join(PKG_ROOT, 'install.sh');
16
+ const INIT_SCRIPT = path.join(PKG_ROOT, 'bin', 'openclaw-node-init.js');
19
17
 
20
18
  // ── Sanity checks ──
21
- if (!fs.existsSync(INSTALL_SCRIPT)) {
22
- console.error('ERROR: install.sh not found at', INSTALL_SCRIPT);
23
- console.error('Package may be corrupted. Reinstall with: npx openclaw-node-harness');
19
+ if (!fs.existsSync(INIT_SCRIPT)) {
20
+ console.error('ERROR: bin/openclaw-node-init.js not found at', INIT_SCRIPT);
21
+ console.error('Package may be corrupted. Reinstall with: npx openclaw-node-harness@latest');
24
22
  process.exit(1);
25
23
  }
26
24
 
27
- // ── Forward CLI args to install.sh ──
25
+ // ── Forward CLI args to init script ──
28
26
  const userArgs = process.argv.slice(2);
29
27
 
30
- const result = spawnSync('bash', [INSTALL_SCRIPT, ...userArgs], {
28
+ const result = spawnSync(process.execPath, [INIT_SCRIPT, ...userArgs], {
31
29
  stdio: 'inherit',
32
30
  env: { ...process.env },
33
31
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-node-harness",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "One-command installer for the OpenClaw node layer — identity, skills, souls, daemon, and Mission Control.",
5
5
  "bin": {
6
6
  "openclaw-node": "./cli.js"
@@ -41,5 +41,8 @@
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=18"
44
+ },
45
+ "dependencies": {
46
+ "nats": "^2.28.2"
44
47
  }
45
48
  }