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.
- package/bin/mesh-join-token.js +19 -1
- package/bin/openclaw-node-init.js +183 -124
- package/cli.js +8 -10
- package/package.json +4 -1
package/bin/mesh-join-token.js
CHANGED
|
@@ -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:
|
|
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 —
|
|
4
|
+
* openclaw-node-init.js — Zero-config mesh node provisioner.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
+
* 3. Dependency checks (Node.js, git)
|
|
11
12
|
* 4. Directory structure (~/.openclaw/)
|
|
12
|
-
* 5. NATS configuration (
|
|
13
|
-
* 6. Mesh code installation (
|
|
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
|
-
*
|
|
19
|
-
* node bin/openclaw-node-init.js
|
|
20
|
-
* node bin/openclaw-node-init.js --
|
|
21
|
-
* node bin/openclaw-node-init.js --
|
|
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
|
|
44
|
-
const PROVIDER_OVERRIDE = getArg('--provider',
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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(
|
|
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: '
|
|
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
|
|
280
|
-
// npm install to ensure deps are current
|
|
373
|
+
ok(`Mesh code exists at ${meshDir}`);
|
|
281
374
|
if (!DRY_RUN) {
|
|
282
|
-
log('
|
|
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('
|
|
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;
|
|
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
|
|
444
|
-
} catch
|
|
445
|
-
warn(`Could not enable linger
|
|
446
|
-
warn(
|
|
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 <
|
|
466
|
-
spawnSync('sleep', ['1']);
|
|
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 (
|
|
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
|
|
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:
|
|
573
|
-
step(1, '
|
|
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
|
|
603
|
-
step(
|
|
604
|
-
const { deps, missing } = checkDependencies(
|
|
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('
|
|
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
|
|
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}
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
16
|
+
const INIT_SCRIPT = path.join(PKG_ROOT, 'bin', 'openclaw-node-init.js');
|
|
19
17
|
|
|
20
18
|
// ── Sanity checks ──
|
|
21
|
-
if (!fs.existsSync(
|
|
22
|
-
console.error('ERROR:
|
|
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
|
|
25
|
+
// ── Forward CLI args to init script ──
|
|
28
26
|
const userArgs = process.argv.slice(2);
|
|
29
27
|
|
|
30
|
-
const result = spawnSync(
|
|
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.
|
|
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
|
}
|