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.
- package/bin/fleet-deploy.js +1 -1
- package/bin/mesh-agent.js +3 -3
- package/bin/mesh-deploy-listener.js +1 -1
- package/bin/mesh-deploy.js +3 -3
- package/bin/mesh-health-publisher.js +1 -1
- package/bin/mesh-join-token.js +19 -1
- package/bin/mesh.js +155 -22
- package/bin/openclaw-node-init.js +330 -127
- package/cli.js +8 -10
- package/lib/mesh-tasks.js +6 -7
- package/package.json +4 -1
package/bin/fleet-deploy.js
CHANGED
|
@@ -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
|
|
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
|
|
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');
|
package/bin/mesh-deploy.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
32
|
+
path.join(os.homedir(), 'openclaw');
|
|
33
33
|
|
|
34
34
|
const sc = StringCodec();
|
|
35
35
|
const IS_MAC = os.platform() === "darwin";
|
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
|
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
|
|
33
|
-
const
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 —
|
|
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;
|
|
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
|
-
|
|
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') {
|
|
@@ -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(`
|
|
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('
|
|
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
|
-
//
|
|
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('
|
|
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
|
|
444
|
-
} catch
|
|
445
|
-
warn(`Could not enable linger
|
|
446
|
-
warn(
|
|
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 <
|
|
466
|
-
spawnSync('sleep', ['1']);
|
|
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 (
|
|
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
|
|
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:
|
|
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...');
|
|
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
|
|
603
|
-
step(
|
|
604
|
-
const { deps, missing } = checkDependencies(
|
|
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('
|
|
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
|
|
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}
|
|
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
|
-
*
|
|
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/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.
|
|
196
|
-
|
|
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.
|
|
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
|
}
|