openclaw-node-harness 2.0.1 → 2.0.3

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.
@@ -23,7 +23,7 @@ const os = require('os');
23
23
 
24
24
  const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
25
25
  const sc = StringCodec();
26
- const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw-node');
26
+ const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw');
27
27
  const NODE_ID = process.env.OPENCLAW_NODE_ID ||
28
28
  os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
29
29
 
package/bin/mesh-agent.js CHANGED
@@ -117,7 +117,7 @@ function buildInitialPrompt(task) {
117
117
  parts.push('');
118
118
  }
119
119
 
120
- if (task.success_criteria.length > 0) {
120
+ if (task.success_criteria && task.success_criteria.length > 0) {
121
121
  parts.push('## Success Criteria');
122
122
  for (const c of task.success_criteria) {
123
123
  parts.push(`- ${c}`);
@@ -132,7 +132,7 @@ function buildInitialPrompt(task) {
132
132
  parts.push('');
133
133
  }
134
134
 
135
- if (task.scope.length > 0) {
135
+ if (task.scope && task.scope.length > 0) {
136
136
  parts.push('## Scope');
137
137
  parts.push('Only modify these files/paths:');
138
138
  for (const s of task.scope) {
@@ -192,7 +192,7 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
192
192
  parts.push('');
193
193
  }
194
194
 
195
- if (task.scope.length > 0) {
195
+ if (task.scope && task.scope.length > 0) {
196
196
  parts.push('## Scope');
197
197
  for (const s of task.scope) {
198
198
  parts.push(`- ${s}`);
@@ -26,7 +26,7 @@ const os = require('os');
26
26
  const NODE_ID = process.env.OPENCLAW_NODE_ID ||
27
27
  os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
28
28
  const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
29
- path.join(os.homedir(), 'openclaw-node');
29
+ path.join(os.homedir(), 'openclaw');
30
30
  const DEPLOY_SCRIPT = path.join(REPO_DIR, 'bin', 'mesh-deploy.js');
31
31
 
32
32
  const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * ENVIRONMENT:
34
34
  * OPENCLAW_DEPLOY_BRANCH — git branch (default: main)
35
- * OPENCLAW_REPO_DIR — repo location (default: ~/openclaw-node)
35
+ * OPENCLAW_REPO_DIR — repo location (default: ~/openclaw)
36
36
  * OPENCLAW_NATS — NATS server URL (from env or openclaw.env)
37
37
  */
38
38
 
@@ -47,7 +47,7 @@ const crypto = require('crypto');
47
47
  const IS_MAC = os.platform() === 'darwin';
48
48
  const HOME = os.homedir();
49
49
  const DEPLOY_BRANCH = process.env.OPENCLAW_DEPLOY_BRANCH || 'main';
50
- const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw-node');
50
+ const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw');
51
51
 
52
52
  // Standard directory layout
53
53
  const DIRS = {
@@ -884,7 +884,7 @@ async function main() {
884
884
 
885
885
  if (!fs.existsSync(REPO_DIR)) {
886
886
  fail(`Repo not found at ${REPO_DIR}`);
887
- console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git ${REPO_DIR}`);
887
+ console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git "${REPO_DIR}"`);
888
888
  process.exit(1);
889
889
  }
890
890
 
@@ -29,7 +29,7 @@ const HEALTH_BUCKET = "MESH_NODE_HEALTH";
29
29
  const KV_TTL_MS = 120_000; // entries expire after 2 minutes if node dies
30
30
 
31
31
  const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
32
- path.join(os.homedir(), 'openclaw-node');
32
+ path.join(os.homedir(), 'openclaw');
33
33
 
34
34
  const sc = StringCodec();
35
35
  const IS_MAC = os.platform() === "darwin";
@@ -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
package/bin/mesh.js CHANGED
@@ -29,23 +29,44 @@ const path = require('path');
29
29
  const os = require('os');
30
30
 
31
31
  // ─── Config ──────────────────────────────────────────
32
- // NATS URL resolved via shared lib (env var → openclaw.env → .mesh-config localhost fallback)
33
- const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
32
+ // ── NATS URL resolution: env var → ~/.openclaw/openclaw.env → fallback IP ──
33
+ const NATS_FALLBACK = 'nats://100.91.131.61:4222';
34
+ function resolveNatsUrl() {
35
+ if (process.env.OPENCLAW_NATS) return process.env.OPENCLAW_NATS;
36
+ try {
37
+ const envFile = path.join(os.homedir(), '.openclaw', 'openclaw.env');
38
+ if (fs.existsSync(envFile)) {
39
+ const content = fs.readFileSync(envFile, 'utf8');
40
+ const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
41
+ if (match && match[1].trim()) return match[1].trim();
42
+ }
43
+ } catch {}
44
+ return NATS_FALLBACK;
45
+ }
46
+ const NATS_URL = resolveNatsUrl();
34
47
  const SHARED_DIR = path.join(os.homedir(), 'openclaw', 'shared');
35
48
  const LOCAL_NODE = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
36
49
  const sc = StringCodec();
37
50
 
38
51
  // ─── Known nodes (for --node shortcuts) ──────────────
39
- // Load from ~/.openclaw/mesh-aliases.json if it exists, otherwise empty.
40
- let NODE_ALIASES = {};
41
- try {
42
- const aliasFile = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
43
- if (fs.existsSync(aliasFile)) {
44
- NODE_ALIASES = JSON.parse(fs.readFileSync(aliasFile, 'utf8'));
45
- }
46
- } catch {
47
- // File missing or malformed — proceed with no aliases
52
+ const NODE_ALIASES_DEFAULTS = {
53
+ 'ubuntu': 'calos-vmware-virtual-platform',
54
+ 'linux': 'calos-vmware-virtual-platform',
55
+ 'mac': 'moltymacs-virtual-machine-local',
56
+ 'macos': 'moltymacs-virtual-machine-local',
57
+ };
58
+
59
+ function loadNodeAliases() {
60
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
61
+ try {
62
+ if (fs.existsSync(aliasPath)) {
63
+ const custom = JSON.parse(fs.readFileSync(aliasPath, 'utf8'));
64
+ return { ...NODE_ALIASES_DEFAULTS, ...custom };
65
+ }
66
+ } catch {}
67
+ return NODE_ALIASES_DEFAULTS;
48
68
  }
69
+ const NODE_ALIASES = loadNodeAliases();
49
70
 
50
71
  /**
51
72
  * Resolve a node name — accepts aliases, full IDs, or "self"/"local"
@@ -98,7 +119,7 @@ function checkExecSafety(command) {
98
119
  */
99
120
  async function natsConnect() {
100
121
  try {
101
- return await connect(natsConnectOpts({ timeout: 5000 }));
122
+ return await connect({ servers: NATS_URL, timeout: 5000 });
102
123
  } catch (err) {
103
124
  console.error(`Error: Cannot connect to NATS at ${NATS_URL}`);
104
125
  console.error(`Is the NATS server running? Is Tailscale connected?`);
@@ -140,21 +161,15 @@ async function collectHeartbeats(nc, waitMs = 3000) {
140
161
  uptime: os.uptime(),
141
162
  };
142
163
 
143
- // Force-unsubscribe after deadline to prevent hanging if no messages arrive
144
- const timer = setTimeout(() => sub.unsubscribe(), waitMs);
145
-
146
164
  // Listen for heartbeats for a few seconds
147
165
  const deadline = Date.now() + waitMs;
148
166
  for await (const msg of sub) {
149
- try {
150
- const s = JSON.parse(sc.decode(msg.data));
151
- if (s.node !== LOCAL_NODE) {
152
- nodes[s.node] = s;
153
- }
154
- } catch {}
167
+ const s = JSON.parse(sc.decode(msg.data));
168
+ if (s.node !== LOCAL_NODE) {
169
+ nodes[s.node] = s;
170
+ }
155
171
  if (Date.now() >= deadline) break;
156
172
  }
157
- clearTimeout(timer);
158
173
  sub.unsubscribe();
159
174
  return nodes;
160
175
  }
@@ -575,6 +590,119 @@ async function cmdRepair(args) {
575
590
  }
576
591
  }
577
592
 
593
+ /**
594
+ * mesh deploy [--force] [--component <name>] [--node <name>] — trigger fleet deploy.
595
+ *
596
+ * Publishes mesh.deploy.trigger to NATS. All nodes with mesh-deploy-listener
597
+ * will pull from git and self-deploy. Polls MESH_DEPLOY_RESULTS for status.
598
+ */
599
+ async function cmdDeploy(args) {
600
+ const { execSync } = require('child_process');
601
+ const repoDir = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw');
602
+ const force = args.includes('--force');
603
+
604
+ // Parse --component flags
605
+ const components = [];
606
+ for (let i = 0; i < args.length; i++) {
607
+ if (args[i] === '--component' && args[i + 1]) {
608
+ components.push(args[i + 1]);
609
+ i++;
610
+ }
611
+ }
612
+
613
+ // Parse --node flags (target specific nodes, default: all)
614
+ const targetNodes = [];
615
+ for (let i = 0; i < args.length; i++) {
616
+ if (args[i] === '--node' && args[i + 1]) {
617
+ targetNodes.push(resolveNode(args[i + 1]));
618
+ i++;
619
+ }
620
+ }
621
+
622
+ // Get current SHA and branch
623
+ let sha, branch;
624
+ try {
625
+ sha = execSync('git rev-parse --short HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
626
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
627
+ } catch {
628
+ console.error(`Error: Cannot read git state from ${repoDir}`);
629
+ process.exit(1);
630
+ }
631
+
632
+ console.log(`Deploying ${sha} (${branch})${force ? ' [FORCE]' : ''}`);
633
+ if (components.length > 0) console.log(` Components: ${components.join(', ')}`);
634
+ if (targetNodes.length > 0) console.log(` Targets: ${targetNodes.join(', ')}`);
635
+ else console.log(' Targets: all nodes');
636
+
637
+ const nc = await natsConnect();
638
+
639
+ const trigger = {
640
+ sha,
641
+ branch,
642
+ components: components.length > 0 ? components : ['all'],
643
+ nodes: targetNodes.length > 0 ? targetNodes : ['all'],
644
+ force,
645
+ initiator: LOCAL_NODE,
646
+ timestamp: new Date().toISOString(),
647
+ };
648
+
649
+ // Write "latest" marker so offline nodes can catch up
650
+ try {
651
+ const js = nc.jetstream();
652
+ const resultsKv = await js.views.kv('MESH_DEPLOY_RESULTS', { history: 5, ttl: 7 * 24 * 60 * 60 * 1000 });
653
+ await resultsKv.put('latest', sc.encode(JSON.stringify({ sha, branch })));
654
+ } catch {}
655
+
656
+ // Publish trigger
657
+ nc.publish('mesh.deploy.trigger', sc.encode(JSON.stringify(trigger)));
658
+ await nc.flush();
659
+ console.log('Deploy trigger sent.\n');
660
+
661
+ // Poll for results (15s timeout)
662
+ console.log('Waiting for node responses...');
663
+ const deadline = Date.now() + 15000;
664
+ const seen = new Set();
665
+
666
+ try {
667
+ const js = nc.jetstream();
668
+ const resultsKv = await js.views.kv('MESH_DEPLOY_RESULTS');
669
+
670
+ while (Date.now() < deadline) {
671
+ const allAliasNodes = [...new Set(Object.values(NODE_ALIASES))];
672
+ const checkNodes = targetNodes.length > 0 ? targetNodes : allAliasNodes;
673
+
674
+ for (const nodeId of checkNodes) {
675
+ if (seen.has(nodeId)) continue;
676
+ const key = `${sha}-${nodeId}`;
677
+ try {
678
+ const entry = await resultsKv.get(key);
679
+ if (entry && entry.value) {
680
+ const result = JSON.parse(sc.decode(entry.value));
681
+ if (result.status === 'success' || result.status === 'failed' || result.status === 'skipped') {
682
+ const icon = result.status === 'success' ? '\x1b[32m✓\x1b[0m' : result.status === 'skipped' ? '\x1b[33m-\x1b[0m' : '\x1b[31m✗\x1b[0m';
683
+ console.log(` ${icon} ${nodeId}: ${result.status} (${result.durationSeconds || 0}s)`);
684
+ if (result.errors && result.errors.length > 0) {
685
+ for (const e of result.errors) console.log(` Error: ${e}`);
686
+ }
687
+ seen.add(nodeId);
688
+ }
689
+ }
690
+ } catch {}
691
+ }
692
+
693
+ if (seen.size >= checkNodes.length) break;
694
+ await new Promise(r => setTimeout(r, 2000));
695
+ }
696
+ } catch {}
697
+
698
+ if (seen.size === 0) {
699
+ console.log(' (no responses yet — nodes may still be deploying)');
700
+ }
701
+
702
+ console.log('');
703
+ await nc.close();
704
+ }
705
+
578
706
  /**
579
707
  * mesh help — show usage.
580
708
  */
@@ -602,6 +730,10 @@ function cmdHelp() {
602
730
  ' mesh health --json Health check (JSON output)',
603
731
  ' mesh repair Self-repair this node',
604
732
  ' mesh repair --all Self-repair ALL nodes',
733
+ ' mesh deploy Deploy to all nodes',
734
+ ' mesh deploy --force Force deploy (even if up to date)',
735
+ ' mesh deploy --component <name> Deploy specific component',
736
+ ' mesh deploy --node <name> Deploy to specific node',
605
737
  '',
606
738
  'NODE ALIASES:',
607
739
  ' ubuntu, linux = Ubuntu VM (calos-vmware-virtual-platform)',
@@ -632,6 +764,7 @@ async function main() {
632
764
  case 'tasks': return cmdTasks(args);
633
765
  case 'health': return cmdHealth(args);
634
766
  case 'repair': return cmdRepair(args);
767
+ case 'deploy': return cmdDeploy(args);
635
768
  case 'help':
636
769
  case '--help':
637
770
  case '-h': return cmdHelp();
@@ -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;
81
+ }
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
+ }
71
93
  }
72
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') {
@@ -370,15 +460,60 @@ function installLaunchdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
370
460
  return;
371
461
  }
372
462
 
463
+ // Deploy listener plist
464
+ const deployPlistPath = path.join(plistDir, 'ai.openclaw.deploy-listener.plist');
465
+ const deployPlist = `<?xml version="1.0" encoding="UTF-8"?>
466
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
467
+ <plist version="1.0">
468
+ <dict>
469
+ <key>Label</key>
470
+ <string>ai.openclaw.deploy-listener</string>
471
+ <key>ProgramArguments</key>
472
+ <array>
473
+ <string>${nodeBin}</string>
474
+ <string>${meshDir}/bin/mesh-deploy-listener.js</string>
475
+ </array>
476
+ <key>KeepAlive</key>
477
+ <true/>
478
+ <key>RunAtLoad</key>
479
+ <true/>
480
+ <key>StandardOutPath</key>
481
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-deploy-listener.log</string>
482
+ <key>StandardErrorPath</key>
483
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-deploy-listener.err</string>
484
+ <key>EnvironmentVariables</key>
485
+ <dict>
486
+ <key>OPENCLAW_NATS</key>
487
+ <string>${natsUrl}</string>
488
+ <key>OPENCLAW_NODE_ID</key>
489
+ <string>${nodeId}</string>
490
+ <key>OPENCLAW_NODE_ROLE</key>
491
+ <string>worker</string>
492
+ <key>OPENCLAW_REPO_DIR</key>
493
+ <string>${meshDir}</string>
494
+ <key>PATH</key>
495
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.npm-global/bin</string>
496
+ <key>NODE_PATH</key>
497
+ <string>${meshDir}/node_modules:${meshDir}/lib</string>
498
+ </dict>
499
+ <key>ThrottleInterval</key>
500
+ <integer>30</integer>
501
+ </dict>
502
+ </plist>`;
503
+
373
504
  fs.mkdirSync(plistDir, { recursive: true });
374
505
  fs.writeFileSync(plistPath, plist);
375
- ok(`Launchd service written: ${plistPath}`);
506
+ ok(`Mesh agent service written: ${plistPath}`);
507
+ fs.writeFileSync(deployPlistPath, deployPlist);
508
+ ok(`Deploy listener service written: ${deployPlistPath}`);
376
509
 
377
- // Load the service
378
510
  try {
379
511
  execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
380
512
  execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
381
- ok('Service loaded and started');
513
+ ok('Mesh agent loaded and started');
514
+ execSync(`launchctl unload "${deployPlistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
515
+ execSync(`launchctl load "${deployPlistPath}"`, { stdio: 'pipe' });
516
+ ok('Deploy listener loaded and started');
382
517
  } catch (e) {
383
518
  warn(`Service load warning: ${e.message}`);
384
519
  }
@@ -403,6 +538,7 @@ Environment=MESH_NODE_ID=${nodeId}
403
538
  Environment=MESH_LLM_PROVIDER=${provider}
404
539
  Environment=MESH_WORKSPACE=${os.homedir()}/.openclaw/workspace
405
540
  Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
541
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${os.homedir()}/.local/bin:${os.homedir()}/.npm-global/bin
406
542
  WorkingDirectory=${meshDir}
407
543
 
408
544
  [Install]
@@ -418,33 +554,56 @@ WantedBy=default.target
418
554
  fs.writeFileSync(servicePath, service);
419
555
  ok(`Systemd service written: ${servicePath}`);
420
556
 
421
- // Enable and start
557
+ // Deploy listener service
558
+ const deployServicePath = path.join(serviceDir, 'openclaw-deploy-listener.service');
559
+ const deployService = `[Unit]
560
+ Description=OpenClaw Deploy Listener
561
+ After=network-online.target
562
+ Wants=network-online.target
563
+
564
+ [Service]
565
+ Type=simple
566
+ ExecStart=${nodeBin} ${meshDir}/bin/mesh-deploy-listener.js
567
+ Restart=always
568
+ RestartSec=30
569
+ Environment=OPENCLAW_NATS=${natsUrl}
570
+ Environment=OPENCLAW_NODE_ID=${nodeId}
571
+ Environment=OPENCLAW_NODE_ROLE=worker
572
+ Environment=OPENCLAW_REPO_DIR=${meshDir}
573
+ Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
574
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${os.homedir()}/.local/bin:${os.homedir()}/.npm-global/bin
575
+ WorkingDirectory=${meshDir}
576
+
577
+ [Install]
578
+ WantedBy=default.target
579
+ `;
580
+ fs.writeFileSync(deployServicePath, deployService);
581
+ ok(`Deploy listener service written: ${deployServicePath}`);
582
+
422
583
  try {
423
584
  execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
424
585
  execSync('systemctl --user enable openclaw-mesh-agent', { stdio: 'pipe' });
425
586
  execSync('systemctl --user start openclaw-mesh-agent', { stdio: 'pipe' });
426
- ok('Service enabled and started');
587
+ ok('Mesh agent enabled and started');
588
+ execSync('systemctl --user enable openclaw-deploy-listener', { stdio: 'pipe' });
589
+ execSync('systemctl --user start openclaw-deploy-listener', { stdio: 'pipe' });
590
+ ok('Deploy listener enabled and started');
427
591
  } catch (e) {
428
592
  warn(`Service start warning: ${e.message}`);
429
593
  warn('Try manually: systemctl --user start openclaw-mesh-agent');
430
594
  }
431
595
 
432
- // Enable lingering — requires either sudo or polkit permission.
433
- // Without linger, the service dies when the user logs out.
434
596
  const username = os.userInfo().username;
435
597
  try {
436
- // Try without sudo first (works if polkit allows it)
437
598
  execSync(`loginctl enable-linger ${username}`, { stdio: 'pipe', timeout: 5000 });
438
599
  ok(`Linger enabled for ${username} (service survives logout)`);
439
600
  } catch {
440
601
  try {
441
- // Try with sudo
442
602
  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}`);
603
+ ok(`Linger enabled for ${username} via sudo`);
604
+ } catch {
605
+ warn(`Could not enable linger. Service stops on logout.`);
606
+ warn(`Fix: sudo loginctl enable-linger ${username}`);
448
607
  }
449
608
  }
450
609
  }
@@ -458,19 +617,15 @@ function verifyServiceRunning(osInfo) {
458
617
  }
459
618
 
460
619
  log('Waiting 8s for service to stabilize...');
461
-
462
- // Wait for the service to either stabilize or crash
463
- const waitMs = 8000;
464
620
  const start = Date.now();
465
- while (Date.now() - start < waitMs) {
466
- spawnSync('sleep', ['1']); // cross-platform 1s sleep
621
+ while (Date.now() - start < 8000) {
622
+ spawnSync('sleep', ['1']);
467
623
  }
468
624
 
469
625
  if (osInfo.serviceType === 'launchd') {
470
626
  try {
471
627
  const out = execSync('launchctl list | grep mesh-agent', { encoding: 'utf8', stdio: 'pipe' }).trim();
472
628
  if (out) {
473
- // launchctl list format: PID\tStatus\tLabel
474
629
  const parts = out.split(/\s+/);
475
630
  const pid = parts[0];
476
631
  const exitStatus = parts[1];
@@ -488,7 +643,6 @@ function verifyServiceRunning(osInfo) {
488
643
  return false;
489
644
  }
490
645
  } else {
491
- // systemd
492
646
  try {
493
647
  const result = spawnSync('systemctl', ['--user', 'is-active', 'openclaw-mesh-agent'], { encoding: 'utf8', stdio: 'pipe' });
494
648
  const status = (result.stdout || '').trim();
@@ -519,13 +673,11 @@ async function verifyNatsHealth(natsUrl, nodeId) {
519
673
  }
520
674
 
521
675
  try {
522
- // Dynamic require — nats module should be installed by now
523
676
  const nats = require('nats');
524
677
  const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
525
678
 
526
679
  ok(`NATS connected: ${nc.getServer()}`);
527
680
 
528
- // Publish a health announcement
529
681
  const sc = nats.StringCodec();
530
682
  nc.publish(`mesh.health.${nodeId}`, sc.encode(JSON.stringify({
531
683
  node_id: nodeId,
@@ -535,73 +687,109 @@ async function verifyNatsHealth(natsUrl, nodeId) {
535
687
  arch: os.arch(),
536
688
  timestamp: new Date().toISOString(),
537
689
  })));
538
-
539
690
  ok('Health announcement published');
540
691
 
541
- // Try to reach the task daemon
542
692
  try {
543
693
  const msg = await nc.request('mesh.tasks.list', sc.encode(JSON.stringify({ status: 'queued' })), { timeout: 5000 });
544
694
  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
- }
695
+ if (resp.ok) ok(`Task daemon reachable — ${resp.data.length} queued task(s)`);
550
696
  } catch {
551
- warn('Task daemon not reachable (may not be running yet — this is OK for worker-only nodes)');
697
+ warn('Task daemon not reachable (OK for worker-only nodes)');
552
698
  }
553
699
 
554
700
  await nc.drain();
555
701
  return true;
556
702
  } catch (e) {
557
703
  fail(`NATS connection failed: ${e.message}`);
558
- fail('Check that the NATS server is running and the node has network access');
559
704
  return false;
560
705
  }
561
706
  }
562
707
 
708
+ // ── Mesh Topology Discovery ──────────────────────────
709
+
710
+ async function discoverTopology(natsUrl, localNodeId) {
711
+ log('Discovering mesh topology...');
712
+
713
+ if (DRY_RUN) {
714
+ warn('[DRY RUN] Would query MESH_NODE_HEALTH and write mesh-aliases.json');
715
+ return;
716
+ }
717
+
718
+ try {
719
+ const nats = require('nats');
720
+ const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
721
+ const sc = nats.StringCodec();
722
+ const js = nc.jetstream();
723
+
724
+ const aliases = {};
725
+
726
+ // Query MESH_NODE_HEALTH for all known nodes
727
+ try {
728
+ const kv = await js.views.kv('MESH_NODE_HEALTH');
729
+ const keys = await kv.keys();
730
+ for await (const key of keys) {
731
+ const entry = await kv.get(key);
732
+ if (entry && entry.value) {
733
+ const health = JSON.parse(sc.decode(entry.value));
734
+ const nodeId = health.nodeId || key;
735
+ // Create short alias from node ID (strip common suffixes)
736
+ const short = nodeId
737
+ .replace(/-virtual-machine.*$/i, '')
738
+ .replace(/-vmware.*$/i, '')
739
+ .replace(/-local$/, '');
740
+ aliases[short] = nodeId;
741
+ if (health.role === 'lead') aliases['lead'] = nodeId;
742
+ ok(`Peer: ${nodeId} (${health.role || 'worker'}, ${health.tailscaleIp || 'unknown'})`);
743
+ }
744
+ }
745
+ } catch {
746
+ warn('MESH_NODE_HEALTH bucket not available — skipping topology');
747
+ }
748
+
749
+ // Also add self
750
+ const selfShort = localNodeId
751
+ .replace(/-virtual-machine.*$/i, '')
752
+ .replace(/-vmware.*$/i, '')
753
+ .replace(/-local$/, '');
754
+ aliases[selfShort] = localNodeId;
755
+ aliases['self'] = localNodeId;
756
+
757
+ await nc.drain();
758
+
759
+ if (Object.keys(aliases).length > 1) {
760
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
761
+ fs.writeFileSync(aliasPath, JSON.stringify(aliases, null, 2) + '\n', { mode: 0o644 });
762
+ ok(`Mesh aliases written: ${aliasPath} (${Object.keys(aliases).length} entries)`);
763
+ } else {
764
+ warn('No peers found in MESH_NODE_HEALTH — mesh-aliases.json will only have self');
765
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
766
+ fs.writeFileSync(aliasPath, JSON.stringify(aliases, null, 2) + '\n', { mode: 0o644 });
767
+ }
768
+ } catch (e) {
769
+ warn(`Topology discovery failed: ${e.message} (non-fatal)`);
770
+ }
771
+ }
772
+
563
773
  // ── Main ──────────────────────────────────────────────
564
774
 
565
775
  async function main() {
566
776
  console.log(`\n${BOLD}${CYAN}╔══════════════════════════════════════╗${RESET}`);
567
- console.log(`${BOLD}${CYAN}║ OpenClaw Mesh Node Provisioner ║${RESET}`);
777
+ console.log(`${BOLD}${CYAN}║ OpenClaw Mesh Join Network ║${RESET}`);
568
778
  console.log(`${BOLD}${CYAN}╚══════════════════════════════════════╝${RESET}\n`);
569
779
 
570
780
  if (DRY_RUN) warn('DRY RUN MODE — no changes will be made\n');
571
781
 
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...');
782
+ // ── Step 1: Detect OS ──
783
+ step(1, 'Detecting environment...');
596
784
  const osInfo = detectOS();
597
785
  const nodeId = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
598
786
  ok(`OS: ${osInfo.os} (${osInfo.arch})`);
599
787
  ok(`Node ID: ${nodeId}`);
600
788
  ok(`Service type: ${osInfo.serviceType}`);
601
789
 
602
- // ── Step 3: Check dependencies ──
603
- step(3, 'Checking dependencies...');
604
- const { deps, missing } = checkDependencies(osInfo);
790
+ // ── Step 2: Check & install dependencies (including Tailscale) ──
791
+ step(2, 'Checking dependencies...');
792
+ const { deps, missing } = checkDependencies();
605
793
  for (const d of deps) {
606
794
  if (d.status === 'ok') ok(`${d.name}: ${d.detail}`);
607
795
  else if (d.status === 'optional') warn(`${d.name}: ${d.detail}`);
@@ -609,14 +797,30 @@ async function main() {
609
797
  }
610
798
 
611
799
  if (missing.length > 0) {
612
- step('3b', 'Installing missing dependencies...');
800
+ step('2b', 'Installing missing dependencies...');
613
801
  installMissing(missing, osInfo);
614
802
  }
615
803
 
804
+ // ── Step 3: Discover NATS via Tailscale ──
805
+ step(3, 'Discovering NATS server...');
806
+ const natsUrl = await discoverNats();
807
+
808
+ const config = {
809
+ nats: natsUrl,
810
+ provider: PROVIDER_OVERRIDE,
811
+ repo: REPO,
812
+ };
813
+
616
814
  // ── Step 4: Create directory structure ──
617
815
  step(4, 'Setting up directories...');
618
816
  setupDirectories();
619
817
 
818
+ // ── Step 4b: Provision SSH key (if provided) ──
819
+ if (SSH_PUBKEY) {
820
+ step('4b', 'Provisioning SSH key...');
821
+ provisionSSHKey(SSH_PUBKEY);
822
+ }
823
+
620
824
  // ── Step 5: Configure NATS ──
621
825
  step(5, 'Configuring NATS connection...');
622
826
  configureNats(config.nats);
@@ -629,12 +833,16 @@ async function main() {
629
833
  step(7, `Installing ${osInfo.serviceType} service...`);
630
834
  installService(osInfo, meshDir, config);
631
835
 
632
- // ── Step 8: Verify health (service + NATS) ──
836
+ // ── Step 8: Verify health ──
633
837
  step(8, 'Verifying health...');
634
838
  const serviceAlive = verifyServiceRunning(osInfo);
635
839
  const natsHealthy = await verifyNatsHealth(config.nats, nodeId);
636
840
  const healthy = serviceAlive && natsHealthy;
637
841
 
842
+ // ── Step 9: Discover mesh topology ──
843
+ step(9, 'Discovering mesh topology...');
844
+ await discoverTopology(config.nats, nodeId);
845
+
638
846
  // ── Done ──
639
847
  console.log(`\n${BOLD}${GREEN}═══════════════════════════════════════${RESET}`);
640
848
  if (healthy) {
@@ -646,8 +854,7 @@ async function main() {
646
854
  console.log(`${BOLD}${RED} Service failed to start.${RESET}`);
647
855
  console.log(`${RED} Provisioning complete but agent is not running.${RESET}`);
648
856
  } 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}`);
857
+ console.log(`${BOLD}${YELLOW} Provisioned with warnings.${RESET}`);
651
858
  }
652
859
  console.log(`${BOLD}${GREEN}═══════════════════════════════════════${RESET}\n`);
653
860
 
@@ -661,10 +868,6 @@ async function main() {
661
868
  console.log(` 2. Check service: launchctl list | grep mesh-agent`);
662
869
  console.log(` 3. View logs: tail -f ~/.openclaw/workspace/.tmp/mesh-agent.log`);
663
870
  }
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
871
  console.log('');
669
872
  }
670
873
 
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/lib/mesh-tasks.js CHANGED
@@ -140,15 +140,15 @@ class TaskStore {
140
140
  // Apply filters
141
141
  if (filter.status && task.status !== filter.status) continue;
142
142
  if (filter.owner && task.owner !== filter.owner) continue;
143
- if (filter.tag && !task.tags.includes(filter.tag)) continue;
143
+ if (filter.tag && (!task.tags || !task.tags.includes(filter.tag))) continue;
144
144
 
145
145
  tasks.push(task);
146
146
  }
147
147
 
148
148
  // Sort by priority (higher first), then created_at (older first)
149
149
  tasks.sort((a, b) => {
150
- if (b.priority !== a.priority) return b.priority - a.priority;
151
- return new Date(a.created_at) - new Date(b.created_at);
150
+ if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
151
+ return (new Date(a.created_at || 0)) - (new Date(b.created_at || 0));
152
152
  });
153
153
 
154
154
  return tasks;
@@ -169,7 +169,7 @@ class TaskStore {
169
169
  if (task.exclude_nodes && task.exclude_nodes.includes(nodeId)) continue;
170
170
 
171
171
  // Respect dependencies
172
- if (task.depends_on.length > 0) {
172
+ if (task.depends_on && task.depends_on.length > 0) {
173
173
  const depsReady = await this._checkDeps(task.depends_on);
174
174
  if (!depsReady) continue;
175
175
  }
@@ -192,9 +192,8 @@ class TaskStore {
192
192
  task.status = TASK_STATUS.CLAIMED;
193
193
  task.owner = nodeId;
194
194
  task.claimed_at = new Date().toISOString();
195
- task.budget_deadline = new Date(
196
- Date.now() + task.budget_minutes * 60 * 1000
197
- ).toISOString();
195
+ const budgetMs = (task.budget_minutes || 30) * 60 * 1000;
196
+ task.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
198
197
 
199
198
  await this.put(task);
200
199
  return task;
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.3",
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
  }