openclaw-node-harness 2.0.0 → 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.
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mesh-join-token.js — Generate a join token for new mesh nodes.
5
+ *
6
+ * Run this on the lead node. It produces a base64-encoded token containing
7
+ * everything a new node needs to join the mesh:
8
+ * - NATS URL
9
+ * - Mesh node role (worker by default)
10
+ * - Default LLM provider
11
+ * - Token expiry
12
+ * - HMAC signature (shared-secret integrity check)
13
+ *
14
+ * Usage:
15
+ * node bin/mesh-join-token.js # generate with defaults
16
+ * node bin/mesh-join-token.js --role worker # explicit role
17
+ * node bin/mesh-join-token.js --provider deepseek # set default LLM
18
+ * node bin/mesh-join-token.js --expires 24h # custom expiry (default: 48h)
19
+ * node bin/mesh-join-token.js --one-liner # output curl | sh one-liner
20
+ *
21
+ * The token is NOT encrypted — it's signed for integrity. Don't embed secrets
22
+ * (API keys). Those go in ~/.openclaw/openclaw.env on the target node.
23
+ */
24
+
25
+ const crypto = require('crypto');
26
+ const os = require('os');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const { NATS_URL } = require('../lib/nats-resolve');
31
+
32
+ // ── CLI args ──────────────────────────────────────────
33
+
34
+ const args = process.argv.slice(2);
35
+
36
+ function getArg(flag, defaultVal) {
37
+ const idx = args.indexOf(flag);
38
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
39
+ }
40
+
41
+ const ROLE = getArg('--role', 'worker');
42
+ const PROVIDER = getArg('--provider', 'claude');
43
+ const EXPIRES = getArg('--expires', '48h');
44
+ const REPO = getArg('--repo', 'https://github.com/moltyguibros-design/openclaw-node.git');
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
+ }
61
+
62
+ // ── Token secret ──────────────────────────────────────
63
+ // Stored at ~/.openclaw/.mesh-secret. Created on first use.
64
+
65
+ const SECRET_PATH = path.join(os.homedir(), '.openclaw', '.mesh-secret');
66
+
67
+ function getOrCreateSecret() {
68
+ try {
69
+ if (fs.existsSync(SECRET_PATH)) {
70
+ return fs.readFileSync(SECRET_PATH, 'utf8').trim();
71
+ }
72
+ } catch { /* fall through */ }
73
+
74
+ const secret = crypto.randomBytes(32).toString('hex');
75
+ fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true });
76
+ fs.writeFileSync(SECRET_PATH, secret, { mode: 0o600 });
77
+ return secret;
78
+ }
79
+
80
+ // ── Expiry parsing ────────────────────────────────────
81
+
82
+ function parseExpiry(str) {
83
+ const match = str.match(/^(\d+)(h|d|m)$/);
84
+ if (!match) throw new Error(`Invalid expiry format: "${str}". Use: 24h, 7d, 30m`);
85
+ const val = parseInt(match[1]);
86
+ const unit = match[2];
87
+ const ms = unit === 'h' ? val * 3600000
88
+ : unit === 'd' ? val * 86400000
89
+ : val * 60000;
90
+ return Date.now() + ms;
91
+ }
92
+
93
+ // ── Generate token ────────────────────────────────────
94
+
95
+ function generateToken() {
96
+ const secret = getOrCreateSecret();
97
+ const expiresAt = parseExpiry(EXPIRES);
98
+
99
+ const sshPubkey = getLeadSSHPubkey();
100
+
101
+ const payload = {
102
+ v: 3, // token version (v3: added ssh_pubkey)
103
+ nats: NATS_URL, // NATS server URL
104
+ role: ROLE, // node role
105
+ provider: PROVIDER, // default LLM provider
106
+ repo: REPO, // mesh code repo URL
107
+ lead: os.hostname(), // lead node hostname (for reference)
108
+ issued: Date.now(), // issued timestamp
109
+ expires: expiresAt, // expiry timestamp
110
+ ...(sshPubkey && { ssh_pubkey: sshPubkey }), // lead node's SSH public key
111
+ };
112
+
113
+ // HMAC-SHA256 signature for integrity
114
+ const payloadStr = JSON.stringify(payload);
115
+ const hmac = crypto.createHmac('sha256', secret).update(payloadStr).digest('hex');
116
+
117
+ // Encode as base64url (no padding, url-safe)
118
+ const token = Buffer.from(JSON.stringify({ p: payload, s: hmac }))
119
+ .toString('base64url');
120
+
121
+ return { token, payload, hmac };
122
+ }
123
+
124
+ // ── Main ──────────────────────────────────────────────
125
+
126
+ const { token, payload } = generateToken();
127
+
128
+ if (ONE_LINER) {
129
+ console.log(`curl -fsSL https://raw.githubusercontent.com/moltyguibros-design/openclaw-node/main/mesh-install.sh | MESH_JOIN_TOKEN=${token} sh`);
130
+ } else {
131
+ console.log('\n--- Mesh Join Token ---');
132
+ console.log(`Role: ${payload.role}`);
133
+ console.log(`NATS: ${payload.nats}`);
134
+ console.log(`Provider: ${payload.provider}`);
135
+ console.log(`Repo: ${payload.repo}`);
136
+ console.log(`Expires: ${new Date(payload.expires).toISOString()}`);
137
+ console.log(`Lead: ${payload.lead}`);
138
+ console.log('');
139
+ console.log('Token:');
140
+ console.log(token);
141
+ console.log('');
142
+ console.log('--- Quick Install (paste on target machine) ---');
143
+ console.log(`MESH_JOIN_TOKEN=${token} bash <(curl -fsSL https://raw.githubusercontent.com/moltyguibros-design/openclaw-node/main/mesh-install.sh)`);
144
+ console.log('');
145
+ console.log('--- Or manual install ---');
146
+ console.log(`git clone https://github.com/moltyguibros-design/openclaw-node.git && cd openclaw-node && MESH_JOIN_TOKEN=${token} node bin/openclaw-node-init.js`);
147
+ }
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mesh-node-remove.js — Clean removal of a mesh node.
5
+ *
6
+ * Can be run in two modes:
7
+ * 1. LOCAL: node bin/mesh-node-remove.js
8
+ * Removes the local node from the mesh (stops service, cleans KV state)
9
+ *
10
+ * 2. REMOTE: node bin/mesh-node-remove.js --node <nodeId>
11
+ * Removes a remote/dead node from the mesh (cleans KV state only)
12
+ *
13
+ * What it does:
14
+ * - Cancels any tasks claimed/running by the node (releases back to queue)
15
+ * - Publishes mesh.health.<nodeId> with status=removed
16
+ * - Removes node from MESH_NODES KV bucket (if it exists)
17
+ * - (Local mode only) Stops and removes the launchd/systemd service
18
+ * - (Local mode only) Optionally removes ~/.openclaw/ and ~/openclaw/
19
+ *
20
+ * Usage:
21
+ * node bin/mesh-node-remove.js # remove local node
22
+ * node bin/mesh-node-remove.js --node calos # remove dead remote node
23
+ * node bin/mesh-node-remove.js --node calos --force # skip confirmation
24
+ * node bin/mesh-node-remove.js --purge # also delete code + config
25
+ */
26
+
27
+ const { execSync } = require('child_process');
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+ const readline = require('readline');
32
+
33
+ // ── CLI args ──────────────────────────────────────────
34
+
35
+ const args = process.argv.slice(2);
36
+ const FORCE = args.includes('--force');
37
+ const PURGE = args.includes('--purge');
38
+
39
+ function getArg(flag, defaultVal) {
40
+ const idx = args.indexOf(flag);
41
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : defaultVal;
42
+ }
43
+
44
+ const TARGET_NODE = getArg('--node', null);
45
+ const LOCAL_MODE = !TARGET_NODE;
46
+ const NODE_ID = TARGET_NODE || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
47
+
48
+ // ── Logging ───────────────────────────────────────────
49
+
50
+ const BOLD = '\x1b[1m';
51
+ const GREEN = '\x1b[32m';
52
+ const RED = '\x1b[31m';
53
+ const YELLOW = '\x1b[33m';
54
+ const CYAN = '\x1b[36m';
55
+ const RESET = '\x1b[0m';
56
+
57
+ function log(msg) { console.log(`${CYAN}[mesh-remove]${RESET} ${msg}`); }
58
+ function ok(msg) { console.log(`${GREEN} ✓${RESET} ${msg}`); }
59
+ function warn(msg){ console.log(`${YELLOW} ⚠${RESET} ${msg}`); }
60
+ function fail(msg){ console.error(`${RED} ✗${RESET} ${msg}`); }
61
+
62
+ // ── Confirmation ─────────────────────────────────────
63
+
64
+ async function confirm(msg) {
65
+ if (FORCE) return true;
66
+
67
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
+ return new Promise(resolve => {
69
+ rl.question(`${YELLOW}${msg} [y/N]: ${RESET}`, answer => {
70
+ rl.close();
71
+ resolve(answer.trim().toLowerCase() === 'y');
72
+ });
73
+ });
74
+ }
75
+
76
+ // ── NATS Operations ──────────────────────────────────
77
+
78
+ async function cleanNatsState(nodeId) {
79
+ let natsUrl;
80
+ try {
81
+ const { NATS_URL } = require('../lib/nats-resolve');
82
+ natsUrl = NATS_URL;
83
+ } catch {
84
+ // If we're running after code deletion, try env
85
+ natsUrl = process.env.OPENCLAW_NATS || 'nats://100.91.131.61:4222';
86
+ }
87
+
88
+ let nats;
89
+ try {
90
+ nats = require('nats');
91
+ } catch {
92
+ warn('NATS module not available — skipping KV cleanup');
93
+ warn('Tasks claimed by this node may need manual cleanup');
94
+ return;
95
+ }
96
+
97
+ const sc = nats.StringCodec();
98
+
99
+ try {
100
+ const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
101
+ ok(`NATS connected: ${nc.getServer()}`);
102
+
103
+ const js = nc.jetstream();
104
+
105
+ // 1. Release/cancel tasks owned by this node
106
+ try {
107
+ const kv = await js.views.kv('MESH_TASKS');
108
+ const keys = await kv.keys();
109
+ const allKeys = [];
110
+ for await (const k of keys) allKeys.push(k);
111
+
112
+ let released = 0;
113
+ for (const k of allKeys) {
114
+ const entry = await kv.get(k);
115
+ if (!entry || !entry.value) continue;
116
+ const task = JSON.parse(new TextDecoder().decode(entry.value));
117
+
118
+ if (task.owner === nodeId && (task.status === 'claimed' || task.status === 'running')) {
119
+ task.status = 'queued';
120
+ task.owner = null;
121
+ task.claimed_at = null;
122
+ task.started_at = null;
123
+ task.budget_deadline = null;
124
+ task.last_activity = null;
125
+ task.updated_at = new Date().toISOString();
126
+ await kv.put(k, JSON.stringify(task));
127
+ released++;
128
+ }
129
+ }
130
+
131
+ if (released > 0) {
132
+ ok(`Released ${released} task(s) back to queue`);
133
+ } else {
134
+ ok('No active tasks to release');
135
+ }
136
+ } catch (e) {
137
+ warn(`Task cleanup error: ${e.message}`);
138
+ }
139
+
140
+ // 2. Clean node registry (if MESH_NODES bucket exists)
141
+ try {
142
+ const nodeKv = await js.views.kv('MESH_NODES');
143
+ await nodeKv.delete(nodeId);
144
+ ok(`Removed ${nodeId} from MESH_NODES registry`);
145
+ } catch {
146
+ // Bucket may not exist — that's fine
147
+ ok('No MESH_NODES registry entry to remove');
148
+ }
149
+
150
+ // 3. Publish removal announcement
151
+ nc.publish(`mesh.health.${nodeId}`, sc.encode(JSON.stringify({
152
+ node_id: nodeId,
153
+ status: 'removed',
154
+ event: 'node_removed',
155
+ timestamp: new Date().toISOString(),
156
+ })));
157
+ ok('Removal announcement published');
158
+
159
+ await nc.drain();
160
+ } catch (e) {
161
+ warn(`NATS cleanup failed: ${e.message}`);
162
+ warn('The node may still appear in mesh state until TTL expires');
163
+ }
164
+ }
165
+
166
+ // ── Local Service Removal ────────────────────────────
167
+
168
+ function removeLocalService() {
169
+ const platform = os.platform();
170
+
171
+ if (platform === 'darwin') {
172
+ // launchd
173
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'ai.openclaw.mesh-agent.plist');
174
+ if (fs.existsSync(plistPath)) {
175
+ try {
176
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
177
+ fs.unlinkSync(plistPath);
178
+ ok('Launchd service stopped and removed');
179
+ } catch (e) {
180
+ warn(`Service removal warning: ${e.message}`);
181
+ }
182
+ } else {
183
+ ok('No launchd service to remove');
184
+ }
185
+ } else if (platform === 'linux') {
186
+ // systemd
187
+ try {
188
+ execSync('systemctl --user stop openclaw-mesh-agent 2>/dev/null || true', { stdio: 'pipe' });
189
+ execSync('systemctl --user disable openclaw-mesh-agent 2>/dev/null || true', { stdio: 'pipe' });
190
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', 'openclaw-mesh-agent.service');
191
+ if (fs.existsSync(servicePath)) {
192
+ fs.unlinkSync(servicePath);
193
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
194
+ }
195
+ ok('Systemd service stopped, disabled, and removed');
196
+ } catch (e) {
197
+ warn(`Service removal warning: ${e.message}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // ── Purge Local Files ────────────────────────────────
203
+
204
+ function purgeLocalFiles() {
205
+ const dirs = [
206
+ path.join(os.homedir(), '.openclaw'),
207
+ path.join(os.homedir(), 'openclaw'),
208
+ ];
209
+
210
+ for (const dir of dirs) {
211
+ if (fs.existsSync(dir)) {
212
+ try {
213
+ fs.rmSync(dir, { recursive: true, force: true });
214
+ ok(`Removed: ${dir}`);
215
+ } catch (e) {
216
+ warn(`Could not remove ${dir}: ${e.message}`);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ // ── Main ──────────────────────────────────────────────
223
+
224
+ async function main() {
225
+ console.log(`\n${BOLD}${RED}╔══════════════════════════════════════╗${RESET}`);
226
+ console.log(`${BOLD}${RED}║ OpenClaw Mesh — Node Removal ║${RESET}`);
227
+ console.log(`${BOLD}${RED}╚══════════════════════════════════════╝${RESET}\n`);
228
+
229
+ log(`Target node: ${BOLD}${NODE_ID}${RESET}`);
230
+ log(`Mode: ${LOCAL_MODE ? 'LOCAL (this machine)' : 'REMOTE (KV cleanup only)'}`);
231
+ if (PURGE) log(`${RED}PURGE MODE — will delete all local files${RESET}`);
232
+ console.log('');
233
+
234
+ // Confirmation
235
+ const action = LOCAL_MODE
236
+ ? `Remove local node "${NODE_ID}" from the mesh?`
237
+ : `Remove remote node "${NODE_ID}" from mesh state?`;
238
+
239
+ if (!(await confirm(action))) {
240
+ log('Cancelled.');
241
+ return;
242
+ }
243
+
244
+ // 1. Clean NATS state (both modes)
245
+ log('Cleaning mesh state...');
246
+ await cleanNatsState(NODE_ID);
247
+
248
+ // 2. Stop local service (local mode only)
249
+ if (LOCAL_MODE) {
250
+ log('Removing local service...');
251
+ removeLocalService();
252
+ }
253
+
254
+ // 3. Purge files (local mode + --purge only)
255
+ if (LOCAL_MODE && PURGE) {
256
+ if (await confirm('Delete ~/.openclaw/ and ~/openclaw/ permanently?')) {
257
+ log('Purging local files...');
258
+ purgeLocalFiles();
259
+ }
260
+ }
261
+
262
+ // Done
263
+ console.log(`\n${BOLD}${GREEN}═══════════════════════════════════════${RESET}`);
264
+ console.log(`${BOLD}${GREEN} Node "${NODE_ID}" removed from mesh.${RESET}`);
265
+ console.log(`${BOLD}${GREEN}═══════════════════════════════════════${RESET}\n`);
266
+
267
+ if (!LOCAL_MODE) {
268
+ console.log('Note: This only cleaned mesh state. If the node is still');
269
+ console.log('running, its agent will reconnect and re-register.');
270
+ console.log('To fully remove, also stop the service on that machine.');
271
+ }
272
+ }
273
+
274
+ main().catch(err => {
275
+ fail(`Fatal: ${err.message}`);
276
+ process.exit(1);
277
+ });