nodeclaw 0.1.0-alpha.1

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/dist/cli.js ADDED
@@ -0,0 +1,1754 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ PROTOCOL_VERSION,
4
+ VERSION
5
+ } from "./chunk-F5XQ5PZF.js";
6
+
7
+ // src/cli.ts
8
+ import { Command } from "commander";
9
+
10
+ // src/pairing/pair.ts
11
+ import os2 from "os";
12
+
13
+ // src/crypto/identity.ts
14
+ import crypto from "crypto";
15
+ import fs from "fs";
16
+ import path from "path";
17
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
18
+ function base64UrlEncode(buf) {
19
+ return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
20
+ }
21
+ function derivePublicKeyRaw(publicKeyPem) {
22
+ const key = crypto.createPublicKey(publicKeyPem);
23
+ const spki = key.export({ type: "spki", format: "der" });
24
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
25
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
26
+ }
27
+ return spki;
28
+ }
29
+ function fingerprintPublicKey(publicKeyPem) {
30
+ const raw = derivePublicKeyRaw(publicKeyPem);
31
+ return crypto.createHash("sha256").update(raw).digest("hex");
32
+ }
33
+ function generateIdentity() {
34
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
35
+ const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
36
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
37
+ const deviceId = fingerprintPublicKey(publicKeyPem);
38
+ return { deviceId, publicKeyPem, privateKeyPem };
39
+ }
40
+ function loadOrCreateIdentity(filePath) {
41
+ try {
42
+ if (fs.existsSync(filePath)) {
43
+ const raw = fs.readFileSync(filePath, "utf8");
44
+ const parsed = JSON.parse(raw);
45
+ if (parsed?.version === 1 && typeof parsed.deviceId === "string" && typeof parsed.publicKeyPem === "string" && typeof parsed.privateKeyPem === "string") {
46
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
47
+ if (derivedId && derivedId !== parsed.deviceId) {
48
+ const updated = { ...parsed, deviceId: derivedId };
49
+ fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}
50
+ `, { mode: 384 });
51
+ return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
52
+ }
53
+ return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
54
+ }
55
+ }
56
+ } catch {
57
+ }
58
+ const identity = generateIdentity();
59
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
60
+ const stored = {
61
+ version: 1,
62
+ deviceId: identity.deviceId,
63
+ publicKeyPem: identity.publicKeyPem,
64
+ privateKeyPem: identity.privateKeyPem,
65
+ createdAtMs: Date.now()
66
+ };
67
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}
68
+ `, { mode: 384 });
69
+ return identity;
70
+ }
71
+ function signDevicePayload(privateKeyPem, payload) {
72
+ const key = crypto.createPrivateKey(privateKeyPem);
73
+ const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
74
+ return base64UrlEncode(sig);
75
+ }
76
+ function publicKeyRawBase64Url(publicKeyPem) {
77
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
78
+ }
79
+
80
+ // src/crypto/device-auth.ts
81
+ function normalizeMetadataForAuth(value) {
82
+ if (typeof value !== "string") {
83
+ return "";
84
+ }
85
+ const trimmed = value.trim();
86
+ if (!trimmed) {
87
+ return "";
88
+ }
89
+ return trimmed.replace(
90
+ /[A-Z]/g,
91
+ (ch) => String.fromCharCode(ch.charCodeAt(0) + 32)
92
+ );
93
+ }
94
+ function buildDeviceAuthPayloadV3(params) {
95
+ const scopes = params.scopes.join(",");
96
+ const token = params.token ?? "";
97
+ const platform = normalizeMetadataForAuth(params.platform);
98
+ const deviceFamily = normalizeMetadataForAuth(params.deviceFamily);
99
+ return [
100
+ "v3",
101
+ params.deviceId,
102
+ params.clientId,
103
+ params.clientMode,
104
+ params.role,
105
+ scopes,
106
+ String(params.signedAtMs),
107
+ token,
108
+ params.nonce,
109
+ platform,
110
+ deviceFamily
111
+ ].join("|");
112
+ }
113
+
114
+ // src/crypto/token-store.ts
115
+ import fs2 from "fs";
116
+ import path2 from "path";
117
+ function readStore(filePath) {
118
+ try {
119
+ if (!fs2.existsSync(filePath)) {
120
+ return null;
121
+ }
122
+ const raw = fs2.readFileSync(filePath, "utf8");
123
+ const parsed = JSON.parse(raw);
124
+ if (parsed?.version !== 1 || typeof parsed.deviceId !== "string" || !parsed.tokens || typeof parsed.tokens !== "object") {
125
+ return null;
126
+ }
127
+ return parsed;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ function writeStore(filePath, store) {
133
+ fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
134
+ fs2.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}
135
+ `, {
136
+ mode: 384
137
+ });
138
+ }
139
+ function loadDeviceAuthToken(filePath, deviceId, role) {
140
+ const store = readStore(filePath);
141
+ if (!store || store.deviceId !== deviceId) {
142
+ return null;
143
+ }
144
+ const entry = store.tokens[role];
145
+ if (!entry || typeof entry.token !== "string") {
146
+ return null;
147
+ }
148
+ return entry;
149
+ }
150
+ function storeDeviceAuthToken(filePath, deviceId, role, token, scopes = []) {
151
+ let store = readStore(filePath);
152
+ if (!store || store.deviceId !== deviceId) {
153
+ store = { version: 1, deviceId, tokens: {} };
154
+ }
155
+ const entry = {
156
+ token,
157
+ scopes,
158
+ issuedAtMs: Date.now()
159
+ };
160
+ store.tokens[role] = entry;
161
+ writeStore(filePath, store);
162
+ return entry;
163
+ }
164
+ function clearDeviceAuthToken(filePath, deviceId, role) {
165
+ const store = readStore(filePath);
166
+ if (!store || store.deviceId !== deviceId) {
167
+ return;
168
+ }
169
+ delete store.tokens[role];
170
+ writeStore(filePath, store);
171
+ }
172
+
173
+ // src/config/schema.ts
174
+ import { z } from "zod";
175
+ var GatewayConfigSchema = z.object({
176
+ url: z.string().url().default("ws://127.0.0.1:18789"),
177
+ tlsVerify: z.boolean().default(true)
178
+ });
179
+ var DeviceConfigSchema = z.object({
180
+ name: z.string().default(""),
181
+ workdir: z.string().default("")
182
+ });
183
+ var ExecConfigSchema = z.object({
184
+ blockedCommands: z.array(z.string()).default([]),
185
+ timeoutMs: z.number().int().min(0).default(6e4),
186
+ maxConcurrent: z.number().int().min(1).default(3)
187
+ });
188
+ var LogConfigSchema = z.object({
189
+ level: z.enum(["debug", "info", "warn", "error"]).default("info"),
190
+ path: z.string().optional()
191
+ });
192
+ var NodeClawConfigSchema = z.object({
193
+ gateway: GatewayConfigSchema.default({}),
194
+ device: DeviceConfigSchema.default({}),
195
+ exec: ExecConfigSchema.default({}),
196
+ log: LogConfigSchema.default({})
197
+ });
198
+
199
+ // src/config/paths.ts
200
+ import path3 from "path";
201
+ import os from "os";
202
+ function resolveBaseDir(env = process.env) {
203
+ if (env.NODECLAW_HOME) {
204
+ return env.NODECLAW_HOME;
205
+ }
206
+ return path3.join(os.homedir(), ".nodeclaw");
207
+ }
208
+ function resolveConfigPath(env) {
209
+ return path3.join(resolveBaseDir(env), "config.json");
210
+ }
211
+ function resolveIdentityPath(env) {
212
+ return path3.join(resolveBaseDir(env), "identity", "device.json");
213
+ }
214
+ function resolveTokenStorePath(env) {
215
+ return path3.join(resolveBaseDir(env), "identity", "device-auth.json");
216
+ }
217
+
218
+ // src/config/loader.ts
219
+ import fs3 from "fs";
220
+ import path4 from "path";
221
+ function loadConfig(env) {
222
+ const configPath = resolveConfigPath(env);
223
+ try {
224
+ if (fs3.existsSync(configPath)) {
225
+ const raw = fs3.readFileSync(configPath, "utf8");
226
+ const parsed = JSON.parse(raw);
227
+ return NodeClawConfigSchema.parse(parsed);
228
+ }
229
+ } catch {
230
+ }
231
+ return NodeClawConfigSchema.parse({});
232
+ }
233
+
234
+ // src/client/gateway-client.ts
235
+ import { randomUUID } from "crypto";
236
+ import { WebSocket } from "ws";
237
+
238
+ // src/protocol/constants.ts
239
+ var PROTOCOL_VERSION2 = 3;
240
+ var CLIENT_NAME = "nodeclaw";
241
+ var CLIENT_MODES = {
242
+ NODE: "node",
243
+ BACKEND: "backend",
244
+ PROBE: "probe"
245
+ };
246
+
247
+ // src/protocol/guards.ts
248
+ function isEventFrame(msg) {
249
+ return typeof msg === "object" && msg !== null && msg.type === "event" && typeof msg.event === "string";
250
+ }
251
+ function isResponseFrame(msg) {
252
+ return typeof msg === "object" && msg !== null && msg.type === "res" && typeof msg.id === "string" && typeof msg.ok === "boolean";
253
+ }
254
+ function coerceNodeInvokePayload(payload) {
255
+ if (typeof payload !== "object" || payload === null) {
256
+ return null;
257
+ }
258
+ const obj = payload;
259
+ if (typeof obj.id !== "string" || typeof obj.command !== "string") {
260
+ return null;
261
+ }
262
+ return {
263
+ id: obj.id,
264
+ nodeId: typeof obj.nodeId === "string" ? obj.nodeId : "",
265
+ command: obj.command,
266
+ paramsJSON: typeof obj.paramsJSON === "string" ? obj.paramsJSON : void 0,
267
+ timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : void 0,
268
+ idempotencyKey: typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : void 0
269
+ };
270
+ }
271
+
272
+ // src/client/gateway-client.ts
273
+ var DEFAULT_CONNECT_TIMEOUT_MS = 2e3;
274
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
275
+ var MAX_BACKOFF_MS = 3e4;
276
+ var FORCE_STOP_GRACE_MS = 250;
277
+ var GatewayClient = class {
278
+ ws = null;
279
+ opts;
280
+ pending = /* @__PURE__ */ new Map();
281
+ backoffMs = 1e3;
282
+ closed = false;
283
+ connectNonce = null;
284
+ connectSent = false;
285
+ connectTimer = null;
286
+ lastTick = null;
287
+ tickIntervalMs = 3e4;
288
+ tickTimer = null;
289
+ reconnectTimer = null;
290
+ requestTimeoutMs;
291
+ connectedAtMs = null;
292
+ reconnectCount = 0;
293
+ constructor(opts) {
294
+ this.opts = opts;
295
+ this.requestTimeoutMs = typeof opts.requestTimeoutMs === "number" ? Math.max(1, opts.requestTimeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS;
296
+ }
297
+ start() {
298
+ if (this.closed) {
299
+ return;
300
+ }
301
+ const url = this.opts.url;
302
+ const ws = new WebSocket(url, {
303
+ maxPayload: 25 * 1024 * 1024,
304
+ rejectUnauthorized: this.opts.tlsVerify !== false
305
+ });
306
+ this.ws = ws;
307
+ ws.on("open", () => {
308
+ this.queueConnect();
309
+ });
310
+ ws.on("message", (data) => {
311
+ const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
312
+ this.handleMessage(raw);
313
+ });
314
+ ws.on("close", (code, reason) => {
315
+ const reasonText = typeof reason === "string" ? reason : Buffer.isBuffer(reason) ? reason.toString("utf8") : "";
316
+ if (this.ws === ws) {
317
+ this.ws = null;
318
+ }
319
+ this.flushPendingErrors(
320
+ new Error(`gateway closed (${code}): ${reasonText}`)
321
+ );
322
+ this.scheduleReconnect();
323
+ this.opts.onClose?.(code, reasonText);
324
+ });
325
+ ws.on("error", (err) => {
326
+ if (!this.connectSent) {
327
+ this.opts.onConnectError?.(
328
+ err instanceof Error ? err : new Error(String(err))
329
+ );
330
+ }
331
+ });
332
+ }
333
+ stop() {
334
+ this.closed = true;
335
+ if (this.tickTimer) {
336
+ clearInterval(this.tickTimer);
337
+ this.tickTimer = null;
338
+ }
339
+ if (this.connectTimer) {
340
+ clearTimeout(this.connectTimer);
341
+ this.connectTimer = null;
342
+ }
343
+ if (this.reconnectTimer) {
344
+ clearTimeout(this.reconnectTimer);
345
+ this.reconnectTimer = null;
346
+ }
347
+ const ws = this.ws;
348
+ this.ws = null;
349
+ if (ws) {
350
+ ws.close();
351
+ setTimeout(() => {
352
+ try {
353
+ ws.terminate();
354
+ } catch {
355
+ }
356
+ }, FORCE_STOP_GRACE_MS).unref();
357
+ }
358
+ this.flushPendingErrors(new Error("gateway client stopped"));
359
+ }
360
+ async stopAndWait(timeoutMs = 1e3) {
361
+ this.stop();
362
+ await new Promise((resolve) => {
363
+ const timer = setTimeout(resolve, Math.min(timeoutMs, FORCE_STOP_GRACE_MS + 100));
364
+ timer.unref();
365
+ });
366
+ }
367
+ async request(method, params, opts) {
368
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
369
+ throw new Error("gateway not connected");
370
+ }
371
+ const id = randomUUID();
372
+ const frame = { type: "req", id, method, params };
373
+ this.ws.send(JSON.stringify(frame));
374
+ const timeoutMs = opts?.timeoutMs ?? this.requestTimeoutMs;
375
+ return new Promise((resolve, reject) => {
376
+ const timeout = setTimeout(() => {
377
+ this.pending.delete(id);
378
+ reject(new Error(`request ${method} timed out after ${timeoutMs}ms`));
379
+ }, timeoutMs);
380
+ this.pending.set(id, {
381
+ resolve,
382
+ reject,
383
+ timeout
384
+ });
385
+ });
386
+ }
387
+ isConnected() {
388
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN && this.connectSent;
389
+ }
390
+ getHealth() {
391
+ return {
392
+ connected: this.isConnected(),
393
+ connectedAtMs: this.connectedAtMs,
394
+ lastTickMs: this.lastTick,
395
+ reconnectCount: this.reconnectCount,
396
+ tickIntervalMs: this.tickIntervalMs
397
+ };
398
+ }
399
+ queueConnect() {
400
+ this.connectNonce = null;
401
+ this.connectSent = false;
402
+ const connectTimeoutMs = this.opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
403
+ if (this.connectTimer) {
404
+ clearTimeout(this.connectTimer);
405
+ }
406
+ this.connectTimer = setTimeout(() => {
407
+ if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
408
+ return;
409
+ }
410
+ this.opts.onConnectError?.(new Error("gateway connect challenge timeout"));
411
+ this.ws?.close(1008, "connect challenge timeout");
412
+ }, connectTimeoutMs);
413
+ }
414
+ sendConnect() {
415
+ if (this.connectSent) {
416
+ return;
417
+ }
418
+ const nonce = this.connectNonce?.trim() ?? "";
419
+ if (!nonce) {
420
+ this.opts.onConnectError?.(new Error("gateway connect challenge missing nonce"));
421
+ this.ws?.close(1008, "connect challenge missing nonce");
422
+ return;
423
+ }
424
+ this.connectSent = true;
425
+ if (this.connectTimer) {
426
+ clearTimeout(this.connectTimer);
427
+ this.connectTimer = null;
428
+ }
429
+ const role = this.opts.role ?? "node";
430
+ const scopes = this.opts.scopes ?? ["node.invoke"];
431
+ const platform = this.opts.platform ?? process.platform;
432
+ const signedAtMs = Date.now();
433
+ const device = (() => {
434
+ if (!this.opts.deviceIdentity) {
435
+ return void 0;
436
+ }
437
+ const payload = buildDeviceAuthPayloadV3({
438
+ deviceId: this.opts.deviceIdentity.deviceId,
439
+ clientId: this.opts.clientName ?? CLIENT_NAME,
440
+ clientMode: this.opts.mode ?? CLIENT_MODES.NODE,
441
+ role,
442
+ scopes,
443
+ signedAtMs,
444
+ token: this.opts.token ?? this.opts.deviceToken ?? null,
445
+ nonce,
446
+ platform,
447
+ deviceFamily: this.opts.deviceFamily
448
+ });
449
+ const signature = signDevicePayload(
450
+ this.opts.deviceIdentity.privateKeyPem,
451
+ payload
452
+ );
453
+ return {
454
+ id: this.opts.deviceIdentity.deviceId,
455
+ publicKey: publicKeyRawBase64Url(this.opts.deviceIdentity.publicKeyPem),
456
+ signature,
457
+ signedAt: signedAtMs,
458
+ nonce
459
+ };
460
+ })();
461
+ const auth = this.opts.token || this.opts.deviceToken ? {
462
+ token: this.opts.token,
463
+ deviceToken: this.opts.deviceToken
464
+ } : void 0;
465
+ const params = {
466
+ minProtocol: PROTOCOL_VERSION2,
467
+ maxProtocol: PROTOCOL_VERSION2,
468
+ client: {
469
+ id: this.opts.clientName ?? CLIENT_NAME,
470
+ displayName: this.opts.clientDisplayName,
471
+ version: this.opts.clientVersion ?? VERSION,
472
+ platform,
473
+ deviceFamily: this.opts.deviceFamily,
474
+ mode: this.opts.mode ?? CLIENT_MODES.NODE,
475
+ instanceId: this.opts.instanceId
476
+ },
477
+ caps: this.opts.caps ?? [],
478
+ commands: this.opts.commands,
479
+ permissions: this.opts.permissions,
480
+ pathEnv: this.opts.pathEnv,
481
+ auth,
482
+ role,
483
+ scopes,
484
+ device
485
+ };
486
+ void this.request("connect", params).then((helloOk) => {
487
+ this.backoffMs = 1e3;
488
+ this.connectedAtMs = Date.now();
489
+ this.tickIntervalMs = typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 3e4;
490
+ this.lastTick = Date.now();
491
+ this.startTickWatch();
492
+ this.opts.onHelloOk?.(helloOk);
493
+ }).catch((err) => {
494
+ this.opts.onConnectError?.(
495
+ err instanceof Error ? err : new Error(String(err))
496
+ );
497
+ this.ws?.close(1008, "connect failed");
498
+ });
499
+ }
500
+ handleMessage(raw) {
501
+ let parsed;
502
+ try {
503
+ parsed = JSON.parse(raw);
504
+ } catch {
505
+ return;
506
+ }
507
+ if (isEventFrame(parsed)) {
508
+ this.lastTick = Date.now();
509
+ if (parsed.event === "connect.challenge") {
510
+ const payload = parsed.payload;
511
+ const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
512
+ if (!nonce || nonce.trim().length === 0) {
513
+ this.opts.onConnectError?.(
514
+ new Error("gateway connect challenge missing nonce")
515
+ );
516
+ this.ws?.close(1008, "connect challenge missing nonce");
517
+ return;
518
+ }
519
+ this.connectNonce = nonce.trim();
520
+ this.sendConnect();
521
+ return;
522
+ }
523
+ if (parsed.event === "tick") {
524
+ this.lastTick = Date.now();
525
+ }
526
+ this.opts.onEvent?.(parsed);
527
+ return;
528
+ }
529
+ if (isResponseFrame(parsed)) {
530
+ this.lastTick = Date.now();
531
+ const pending = this.pending.get(parsed.id);
532
+ if (!pending) {
533
+ return;
534
+ }
535
+ this.pending.delete(parsed.id);
536
+ if (pending.timeout) {
537
+ clearTimeout(pending.timeout);
538
+ }
539
+ if (parsed.ok) {
540
+ pending.resolve(parsed.payload);
541
+ } else {
542
+ pending.reject(
543
+ new Error(
544
+ parsed.error?.message ?? `request failed: ${parsed.error?.code ?? "unknown"}`
545
+ )
546
+ );
547
+ }
548
+ }
549
+ }
550
+ scheduleReconnect() {
551
+ if (this.closed) {
552
+ return;
553
+ }
554
+ this.connectedAtMs = null;
555
+ this.reconnectCount++;
556
+ if (this.tickTimer) {
557
+ clearInterval(this.tickTimer);
558
+ this.tickTimer = null;
559
+ }
560
+ const delay = this.backoffMs;
561
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
562
+ this.reconnectTimer = setTimeout(() => this.start(), delay);
563
+ this.reconnectTimer.unref();
564
+ }
565
+ flushPendingErrors(err) {
566
+ for (const [, p] of this.pending) {
567
+ if (p.timeout) {
568
+ clearTimeout(p.timeout);
569
+ }
570
+ p.reject(err);
571
+ }
572
+ this.pending.clear();
573
+ }
574
+ startTickWatch() {
575
+ if (this.tickTimer) {
576
+ clearInterval(this.tickTimer);
577
+ }
578
+ const interval = Math.max(this.tickIntervalMs, 1e3);
579
+ this.tickTimer = setInterval(() => {
580
+ if (this.closed || !this.lastTick) {
581
+ return;
582
+ }
583
+ const gap = Date.now() - this.lastTick;
584
+ if (gap > this.tickIntervalMs * 2) {
585
+ this.ws?.close(4e3, "tick timeout");
586
+ }
587
+ }, interval);
588
+ this.tickTimer.unref();
589
+ }
590
+ };
591
+
592
+ // src/pairing/pair.ts
593
+ async function pairWithGateway(opts) {
594
+ const identityPath = resolveIdentityPath(opts.env);
595
+ const tokenStorePath = resolveTokenStorePath(opts.env);
596
+ const identity = loadOrCreateIdentity(identityPath);
597
+ const role = "node";
598
+ const existingToken = loadDeviceAuthToken(tokenStorePath, identity.deviceId, role);
599
+ if (existingToken) {
600
+ return {
601
+ deviceId: identity.deviceId,
602
+ paired: true,
603
+ message: "Already paired with gateway."
604
+ };
605
+ }
606
+ return new Promise((resolve, reject) => {
607
+ let pairRequestSent = false;
608
+ let timeoutTimer = null;
609
+ const client = new GatewayClient({
610
+ url: opts.gatewayUrl,
611
+ deviceIdentity: identity,
612
+ role,
613
+ scopes: ["node.invoke"],
614
+ caps: ["system"],
615
+ commands: ["system.run", "system.run.prepare", "system.which"],
616
+ clientDisplayName: opts.deviceName || os2.hostname(),
617
+ platform: process.platform,
618
+ mode: "node",
619
+ onHelloOk: (hello) => {
620
+ if (hello.auth?.deviceToken) {
621
+ storeDeviceAuthToken(
622
+ tokenStorePath,
623
+ identity.deviceId,
624
+ role,
625
+ hello.auth.deviceToken,
626
+ hello.auth.scopes
627
+ );
628
+ cleanup();
629
+ resolve({
630
+ deviceId: identity.deviceId,
631
+ paired: true,
632
+ message: "Paired successfully!"
633
+ });
634
+ return;
635
+ }
636
+ if (!pairRequestSent) {
637
+ pairRequestSent = true;
638
+ const params = {
639
+ nodeId: identity.deviceId,
640
+ displayName: opts.deviceName || os2.hostname(),
641
+ platform: process.platform,
642
+ version: VERSION,
643
+ caps: ["system"],
644
+ commands: ["system.run", "system.run.prepare", "system.which"]
645
+ };
646
+ void client.request("node.pair.request", params).then(() => {
647
+ console.log(
648
+ `Pairing request sent. Waiting for approval on the gateway...
649
+ Run "openclaw nodes approve" on your gateway to approve this device.`
650
+ );
651
+ }).catch((err) => {
652
+ cleanup();
653
+ reject(new Error(`Pair request failed: ${String(err)}`));
654
+ });
655
+ }
656
+ },
657
+ onConnectError: (err) => {
658
+ if (!pairRequestSent) {
659
+ }
660
+ },
661
+ onClose: () => {
662
+ }
663
+ });
664
+ const cleanup = () => {
665
+ if (timeoutTimer) {
666
+ clearTimeout(timeoutTimer);
667
+ timeoutTimer = null;
668
+ }
669
+ client.stop();
670
+ };
671
+ timeoutTimer = setTimeout(() => {
672
+ cleanup();
673
+ reject(new Error("Pairing timed out after 5 minutes. Try again."));
674
+ }, 5 * 60 * 1e3);
675
+ client.start();
676
+ });
677
+ }
678
+
679
+ // src/cli/pair.ts
680
+ function registerPairCommand(program2) {
681
+ program2.command("pair").description("Pair this device with an OpenClaw gateway").argument("<gateway-url>", "WebSocket URL of the gateway (e.g. wss://host:18789)").option("-n, --name <name>", "Display name for this device").action(async (gatewayUrl, opts) => {
682
+ try {
683
+ const result = await pairWithGateway({
684
+ gatewayUrl,
685
+ deviceName: opts.name
686
+ });
687
+ console.log(result.message);
688
+ console.log(`Device ID: ${result.deviceId}`);
689
+ process.exit(0);
690
+ } catch (err) {
691
+ console.error(`Pairing failed: ${err instanceof Error ? err.message : String(err)}`);
692
+ process.exit(1);
693
+ }
694
+ });
695
+ }
696
+
697
+ // src/runtime/router.ts
698
+ var CommandRouter = class {
699
+ handlers = /* @__PURE__ */ new Map();
700
+ register(command, handler) {
701
+ this.handlers.set(command, handler);
702
+ }
703
+ async dispatch(payload, client) {
704
+ const handler = this.handlers.get(payload.command);
705
+ if (handler) {
706
+ await handler(payload, client);
707
+ return;
708
+ }
709
+ const result = {
710
+ id: payload.id,
711
+ nodeId: payload.nodeId,
712
+ ok: false,
713
+ error: {
714
+ code: "UNKNOWN_COMMAND",
715
+ message: `Unknown command: ${payload.command}`
716
+ }
717
+ };
718
+ await client.request("node.invoke.result", result);
719
+ }
720
+ };
721
+
722
+ // src/runtime/node-runtime.ts
723
+ import os3 from "os";
724
+ async function startNode(opts = {}) {
725
+ const config = opts.config ?? loadConfig(opts.env);
726
+ const identityPath = resolveIdentityPath(opts.env);
727
+ const tokenStorePath = resolveTokenStorePath(opts.env);
728
+ const identity = loadOrCreateIdentity(identityPath);
729
+ const role = "node";
730
+ const storedAuth = loadDeviceAuthToken(
731
+ tokenStorePath,
732
+ identity.deviceId,
733
+ role
734
+ );
735
+ if (!storedAuth) {
736
+ throw new Error(
737
+ "Not paired with a gateway. Run 'nodeclaw pair <gateway-url>' first."
738
+ );
739
+ }
740
+ const router = opts.router ?? new CommandRouter();
741
+ const client = new GatewayClient({
742
+ url: config.gateway.url,
743
+ deviceIdentity: identity,
744
+ deviceToken: storedAuth.token,
745
+ role,
746
+ scopes: ["node.invoke"],
747
+ caps: ["system"],
748
+ commands: [
749
+ "system.run",
750
+ "system.run.prepare",
751
+ "system.which",
752
+ "system.execApprovals.get",
753
+ "system.execApprovals.set"
754
+ ],
755
+ clientDisplayName: config.device.name || os3.hostname(),
756
+ clientVersion: VERSION,
757
+ platform: process.platform,
758
+ mode: "node",
759
+ tlsVerify: config.gateway.tlsVerify,
760
+ onHelloOk: (hello) => {
761
+ console.log(
762
+ `Connected to gateway (protocol ${hello.protocol ?? "?"}, connId: ${hello.server?.connId ?? "?"})`
763
+ );
764
+ },
765
+ onEvent: (evt) => {
766
+ if (evt.event === "node.invoke.request") {
767
+ const invokePayload = coerceNodeInvokePayload(evt.payload);
768
+ if (invokePayload) {
769
+ void router.dispatch(invokePayload, client).catch((err) => {
770
+ console.error(
771
+ `Handler error for ${invokePayload.command}: ${String(err)}`
772
+ );
773
+ });
774
+ }
775
+ }
776
+ },
777
+ onConnectError: (err) => {
778
+ console.error(`Connection error: ${err.message}`);
779
+ },
780
+ onClose: (code, reason) => {
781
+ if (code !== 1e3) {
782
+ console.log(`Disconnected (${code}): ${reason || "unknown"}`);
783
+ }
784
+ }
785
+ });
786
+ client.start();
787
+ return {
788
+ client,
789
+ router,
790
+ stop: () => client.stop()
791
+ };
792
+ }
793
+
794
+ // src/handlers/system-run.ts
795
+ import { spawn } from "child_process";
796
+ import { randomUUID as randomUUID2 } from "crypto";
797
+ import path5 from "path";
798
+
799
+ // src/handlers/node-events.ts
800
+ var EVENT_OUTPUT_MAX = 2e4;
801
+ function tailOutput(output, max) {
802
+ if (output.length <= max) return output;
803
+ return output.slice(-max);
804
+ }
805
+ async function emitNodeEvent(client, event, payload) {
806
+ const params = {
807
+ event,
808
+ payloadJSON: JSON.stringify(payload)
809
+ };
810
+ try {
811
+ await client.request("node.event", params);
812
+ } catch {
813
+ }
814
+ }
815
+ async function emitExecStarted(client, opts) {
816
+ await emitNodeEvent(client, "exec.started", {
817
+ sessionKey: opts.sessionKey,
818
+ runId: opts.runId,
819
+ host: "node",
820
+ command: opts.command,
821
+ suppressNotifyOnExit: opts.suppressNotifyOnExit
822
+ });
823
+ }
824
+ async function emitExecFinished(client, opts) {
825
+ await emitNodeEvent(client, "exec.finished", {
826
+ sessionKey: opts.sessionKey,
827
+ runId: opts.runId,
828
+ host: "node",
829
+ command: opts.command,
830
+ exitCode: opts.exitCode,
831
+ timedOut: opts.timedOut,
832
+ success: opts.success,
833
+ output: opts.output ? tailOutput(opts.output, EVENT_OUTPUT_MAX) : void 0,
834
+ suppressNotifyOnExit: opts.suppressNotifyOnExit
835
+ });
836
+ }
837
+ async function emitExecDenied(client, opts) {
838
+ await emitNodeEvent(client, "exec.denied", {
839
+ sessionKey: opts.sessionKey,
840
+ runId: opts.runId,
841
+ host: "node",
842
+ command: opts.command,
843
+ reason: opts.reason,
844
+ suppressNotifyOnExit: opts.suppressNotifyOnExit
845
+ });
846
+ }
847
+
848
+ // src/handlers/system-run.ts
849
+ var OUTPUT_CAP = 2e5;
850
+ var activeCount = 0;
851
+ function parseRunParams(paramsJSON) {
852
+ if (!paramsJSON) {
853
+ return null;
854
+ }
855
+ try {
856
+ const parsed = JSON.parse(paramsJSON);
857
+ if (typeof parsed !== "object" || parsed === null) {
858
+ return null;
859
+ }
860
+ const obj = parsed;
861
+ let command = null;
862
+ if (Array.isArray(obj.command)) {
863
+ command = obj.command.filter((s) => typeof s === "string");
864
+ } else if (typeof obj.command === "string" && obj.command.trim()) {
865
+ command = [obj.command];
866
+ } else if (typeof obj.rawCommand === "string" && obj.rawCommand.trim()) {
867
+ command = ["sh", "-c", obj.rawCommand];
868
+ }
869
+ if (!command || command.length === 0) {
870
+ return null;
871
+ }
872
+ return {
873
+ command,
874
+ cwd: typeof obj.cwd === "string" ? obj.cwd : void 0,
875
+ env: typeof obj.env === "object" && obj.env !== null ? obj.env : void 0,
876
+ timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : void 0,
877
+ sessionKey: typeof obj.sessionKey === "string" ? obj.sessionKey : void 0,
878
+ runId: typeof obj.runId === "string" ? obj.runId : void 0,
879
+ suppressNotifyOnExit: typeof obj.suppressNotifyOnExit === "boolean" ? obj.suppressNotifyOnExit : void 0
880
+ };
881
+ } catch {
882
+ return null;
883
+ }
884
+ }
885
+ function isPathWithinWorkdir(testPath, workdir) {
886
+ if (!workdir) {
887
+ return true;
888
+ }
889
+ const resolved = path5.resolve(testPath);
890
+ const resolvedWorkdir = path5.resolve(workdir);
891
+ return resolved === resolvedWorkdir || resolved.startsWith(resolvedWorkdir + path5.sep);
892
+ }
893
+ function isBlockedCommand(command, blockedCommands) {
894
+ if (blockedCommands.length === 0) {
895
+ return false;
896
+ }
897
+ const cmdStr = command.join(" ");
898
+ return blockedCommands.some(
899
+ (blocked) => cmdStr === blocked || cmdStr.startsWith(blocked + " ")
900
+ );
901
+ }
902
+ function runCommand(params, execConfig) {
903
+ return new Promise((resolve) => {
904
+ const timeoutMs = params.timeoutMs ?? execConfig.timeoutMs;
905
+ const [bin, ...args] = params.command;
906
+ const cwd = params.cwd || void 0;
907
+ const env = params.env ? { ...process.env, ...params.env } : void 0;
908
+ const proc = spawn(bin, args, {
909
+ cwd,
910
+ env,
911
+ stdio: ["ignore", "pipe", "pipe"],
912
+ windowsHide: true
913
+ });
914
+ let stdout = "";
915
+ let stderr = "";
916
+ let truncated = false;
917
+ let timedOut = false;
918
+ let totalOutput = 0;
919
+ const appendOutput = (target, chunk) => {
920
+ const remaining = OUTPUT_CAP - totalOutput;
921
+ if (remaining <= 0) {
922
+ truncated = true;
923
+ return;
924
+ }
925
+ const text = chunk.toString("utf8");
926
+ const toAppend = text.length > remaining ? text.slice(0, remaining) : text;
927
+ totalOutput += toAppend.length;
928
+ if (target === "stdout") {
929
+ stdout += toAppend;
930
+ } else {
931
+ stderr += toAppend;
932
+ }
933
+ if (totalOutput >= OUTPUT_CAP) {
934
+ truncated = true;
935
+ }
936
+ };
937
+ proc.stdout.on("data", (chunk) => appendOutput("stdout", chunk));
938
+ proc.stderr.on("data", (chunk) => appendOutput("stderr", chunk));
939
+ let timeoutTimer = null;
940
+ if (timeoutMs > 0) {
941
+ timeoutTimer = setTimeout(() => {
942
+ timedOut = true;
943
+ try {
944
+ proc.kill("SIGKILL");
945
+ } catch {
946
+ }
947
+ }, timeoutMs);
948
+ }
949
+ proc.on("close", (code) => {
950
+ if (timeoutTimer) {
951
+ clearTimeout(timeoutTimer);
952
+ }
953
+ resolve({
954
+ exitCode: code,
955
+ stdout,
956
+ stderr,
957
+ timedOut,
958
+ truncated
959
+ });
960
+ });
961
+ proc.on("error", (err) => {
962
+ if (timeoutTimer) {
963
+ clearTimeout(timeoutTimer);
964
+ }
965
+ resolve({
966
+ exitCode: null,
967
+ stdout,
968
+ stderr: stderr + (stderr ? "\n" : "") + err.message,
969
+ timedOut: false,
970
+ truncated
971
+ });
972
+ });
973
+ });
974
+ }
975
+ function createSystemRunHandler(execConfig, workdir) {
976
+ return async (payload, client) => {
977
+ const sendResult = async (result) => {
978
+ await client.request("node.invoke.result", result);
979
+ };
980
+ const params = parseRunParams(payload.paramsJSON);
981
+ if (!params) {
982
+ await sendResult({
983
+ id: payload.id,
984
+ nodeId: payload.nodeId,
985
+ ok: false,
986
+ error: { code: "INVALID_PARAMS", message: "Invalid system.run params" }
987
+ });
988
+ return;
989
+ }
990
+ const commandText = params.command.join(" ");
991
+ const sessionKey = params.sessionKey ?? "";
992
+ const runId = params.runId ?? randomUUID2();
993
+ const suppressNotifyOnExit = params.suppressNotifyOnExit;
994
+ if (isBlockedCommand(params.command, execConfig.blockedCommands)) {
995
+ void emitExecDenied(client, {
996
+ sessionKey,
997
+ runId,
998
+ command: commandText,
999
+ reason: "allowlist-miss",
1000
+ suppressNotifyOnExit
1001
+ });
1002
+ await sendResult({
1003
+ id: payload.id,
1004
+ nodeId: payload.nodeId,
1005
+ ok: false,
1006
+ error: { code: "BLOCKED", message: "Command is blocked by policy" }
1007
+ });
1008
+ return;
1009
+ }
1010
+ if (params.cwd && !isPathWithinWorkdir(params.cwd, workdir)) {
1011
+ void emitExecDenied(client, {
1012
+ sessionKey,
1013
+ runId,
1014
+ command: commandText,
1015
+ reason: "security=deny",
1016
+ suppressNotifyOnExit
1017
+ });
1018
+ await sendResult({
1019
+ id: payload.id,
1020
+ nodeId: payload.nodeId,
1021
+ ok: false,
1022
+ error: {
1023
+ code: "WORKDIR_VIOLATION",
1024
+ message: `Working directory ${params.cwd} is outside allowed workdir ${workdir}`
1025
+ }
1026
+ });
1027
+ return;
1028
+ }
1029
+ if (activeCount >= execConfig.maxConcurrent) {
1030
+ await sendResult({
1031
+ id: payload.id,
1032
+ nodeId: payload.nodeId,
1033
+ ok: false,
1034
+ error: {
1035
+ code: "RESOURCE_EXHAUSTED",
1036
+ message: `Max concurrent executions (${execConfig.maxConcurrent}) reached`
1037
+ }
1038
+ });
1039
+ return;
1040
+ }
1041
+ activeCount++;
1042
+ void emitExecStarted(client, {
1043
+ sessionKey,
1044
+ runId,
1045
+ command: commandText,
1046
+ suppressNotifyOnExit
1047
+ });
1048
+ try {
1049
+ const result = await runCommand(params, execConfig);
1050
+ void emitExecFinished(client, {
1051
+ sessionKey,
1052
+ runId,
1053
+ command: commandText,
1054
+ exitCode: result.exitCode,
1055
+ timedOut: result.timedOut,
1056
+ success: result.exitCode === 0 && !result.timedOut,
1057
+ output: result.stdout + result.stderr,
1058
+ suppressNotifyOnExit
1059
+ });
1060
+ await sendResult({
1061
+ id: payload.id,
1062
+ nodeId: payload.nodeId,
1063
+ ok: true,
1064
+ payloadJSON: JSON.stringify({
1065
+ exitCode: result.exitCode,
1066
+ stdout: result.stdout,
1067
+ stderr: result.stderr,
1068
+ timedOut: result.timedOut,
1069
+ truncated: result.truncated
1070
+ })
1071
+ });
1072
+ } catch (err) {
1073
+ await sendResult({
1074
+ id: payload.id,
1075
+ nodeId: payload.nodeId,
1076
+ ok: false,
1077
+ error: {
1078
+ code: "EXEC_ERROR",
1079
+ message: err instanceof Error ? err.message : String(err)
1080
+ }
1081
+ });
1082
+ } finally {
1083
+ activeCount--;
1084
+ }
1085
+ };
1086
+ }
1087
+
1088
+ // src/handlers/system-run-prepare.ts
1089
+ import path6 from "path";
1090
+ import crypto2 from "crypto";
1091
+ import fs4 from "fs";
1092
+ function resolveCommandArgv(params) {
1093
+ if (Array.isArray(params.command)) {
1094
+ const argv = params.command.filter(
1095
+ (s) => typeof s === "string"
1096
+ );
1097
+ return argv.length > 0 ? argv : null;
1098
+ }
1099
+ if (typeof params.command === "string" && params.command.trim()) {
1100
+ return [params.command.trim()];
1101
+ }
1102
+ if (typeof params.rawCommand === "string" && params.rawCommand.trim()) {
1103
+ return ["sh", "-c", params.rawCommand.trim()];
1104
+ }
1105
+ return null;
1106
+ }
1107
+ function resolveMutableFileOperand(argv) {
1108
+ for (let i = 1; i < argv.length; i++) {
1109
+ const arg = argv[i];
1110
+ if (arg.startsWith("-")) continue;
1111
+ try {
1112
+ const resolved = path6.resolve(arg);
1113
+ if (fs4.existsSync(resolved) && fs4.statSync(resolved).isFile()) {
1114
+ const content = fs4.readFileSync(resolved);
1115
+ const sha256 = crypto2.createHash("sha256").update(content).digest("hex");
1116
+ return { argvIndex: i, path: resolved, sha256 };
1117
+ }
1118
+ } catch {
1119
+ }
1120
+ }
1121
+ return null;
1122
+ }
1123
+ async function systemRunPrepareHandler(payload, client) {
1124
+ let params = {};
1125
+ if (payload.paramsJSON) {
1126
+ try {
1127
+ params = JSON.parse(payload.paramsJSON);
1128
+ } catch {
1129
+ await client.request("node.invoke.result", {
1130
+ id: payload.id,
1131
+ nodeId: payload.nodeId,
1132
+ ok: false,
1133
+ error: { code: "INVALID_PARAMS", message: "Invalid params" }
1134
+ });
1135
+ return;
1136
+ }
1137
+ }
1138
+ const argv = resolveCommandArgv(params);
1139
+ if (!argv) {
1140
+ await client.request("node.invoke.result", {
1141
+ id: payload.id,
1142
+ nodeId: payload.nodeId,
1143
+ ok: false,
1144
+ error: { code: "INVALID_PARAMS", message: "No command provided" }
1145
+ });
1146
+ return;
1147
+ }
1148
+ const cwd = typeof params.cwd === "string" && params.cwd.trim() ? path6.resolve(params.cwd.trim()) : null;
1149
+ const agentId = typeof params.agentId === "string" ? params.agentId : null;
1150
+ const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : null;
1151
+ const plan = {
1152
+ argv,
1153
+ cwd,
1154
+ commandText: argv.join(" "),
1155
+ commandPreview: argv.length > 3 ? argv.slice(0, 3).join(" ") + "..." : null,
1156
+ agentId,
1157
+ sessionKey,
1158
+ mutableFileOperand: resolveMutableFileOperand(argv)
1159
+ };
1160
+ const result = {
1161
+ id: payload.id,
1162
+ nodeId: payload.nodeId,
1163
+ ok: true,
1164
+ payloadJSON: JSON.stringify({ plan })
1165
+ };
1166
+ await client.request("node.invoke.result", result);
1167
+ }
1168
+
1169
+ // src/handlers/system-info.ts
1170
+ import os4 from "os";
1171
+ import fs5 from "fs";
1172
+ function getSystemInfo(workdir) {
1173
+ const cpus = os4.cpus();
1174
+ const totalMem = os4.totalmem();
1175
+ const freeMem = os4.freemem();
1176
+ const info = {
1177
+ cpu: {
1178
+ model: cpus.length > 0 ? cpus[0].model : "unknown",
1179
+ cores: cpus.length
1180
+ },
1181
+ memory: {
1182
+ totalBytes: totalMem,
1183
+ freeBytes: freeMem,
1184
+ usedBytes: totalMem - freeMem
1185
+ },
1186
+ uptime: os4.uptime(),
1187
+ platform: process.platform,
1188
+ arch: process.arch,
1189
+ hostname: os4.hostname(),
1190
+ nodeVersion: process.version
1191
+ };
1192
+ const statfsPath = workdir || "/";
1193
+ try {
1194
+ const stats = fs5.statfsSync(statfsPath);
1195
+ info.disk = {
1196
+ totalBytes: stats.blocks * stats.bsize,
1197
+ freeBytes: stats.bavail * stats.bsize,
1198
+ usedBytes: (stats.blocks - stats.bavail) * stats.bsize
1199
+ };
1200
+ } catch {
1201
+ }
1202
+ return info;
1203
+ }
1204
+ async function systemInfoHandler(payload, client, workdir) {
1205
+ const info = getSystemInfo(workdir);
1206
+ const result = {
1207
+ id: payload.id,
1208
+ nodeId: payload.nodeId,
1209
+ ok: true,
1210
+ payloadJSON: JSON.stringify(info)
1211
+ };
1212
+ await client.request("node.invoke.result", result);
1213
+ }
1214
+
1215
+ // src/handlers/system-which.ts
1216
+ import { execFileSync } from "child_process";
1217
+ function whichBin(bin) {
1218
+ try {
1219
+ const result = execFileSync("which", [bin], {
1220
+ encoding: "utf8",
1221
+ timeout: 5e3,
1222
+ stdio: ["ignore", "pipe", "ignore"]
1223
+ });
1224
+ return result.trim() || null;
1225
+ } catch {
1226
+ return null;
1227
+ }
1228
+ }
1229
+ function resolveWhich(bins) {
1230
+ const result = {};
1231
+ for (const bin of bins) {
1232
+ result[bin] = whichBin(bin);
1233
+ }
1234
+ return result;
1235
+ }
1236
+ async function systemWhichHandler(payload, client) {
1237
+ let bins = [];
1238
+ if (payload.paramsJSON) {
1239
+ try {
1240
+ const parsed = JSON.parse(payload.paramsJSON);
1241
+ if (Array.isArray(parsed.bins)) {
1242
+ bins = parsed.bins.filter((b) => typeof b === "string");
1243
+ }
1244
+ } catch {
1245
+ }
1246
+ }
1247
+ const resolved = resolveWhich(bins);
1248
+ const result = {
1249
+ id: payload.id,
1250
+ nodeId: payload.nodeId,
1251
+ ok: true,
1252
+ payloadJSON: JSON.stringify({ bins: resolved })
1253
+ };
1254
+ await client.request("node.invoke.result", result);
1255
+ }
1256
+
1257
+ // src/handlers/exec-approvals.ts
1258
+ import crypto3 from "crypto";
1259
+ import fs6 from "fs";
1260
+ import path7 from "path";
1261
+ function resolveExecApprovalsPath(env) {
1262
+ return path7.join(resolveBaseDir(env), "exec-approvals.json");
1263
+ }
1264
+ function hashRaw(raw) {
1265
+ return crypto3.createHash("sha256").update(raw ?? "").digest("hex");
1266
+ }
1267
+ function loadExecApprovalsFile(filePath) {
1268
+ try {
1269
+ if (fs6.existsSync(filePath)) {
1270
+ const raw = fs6.readFileSync(filePath, "utf8");
1271
+ const parsed = JSON.parse(raw);
1272
+ if (parsed?.version === 1) {
1273
+ return { path: filePath, exists: true, raw, file: parsed, hash: hashRaw(raw) };
1274
+ }
1275
+ }
1276
+ } catch {
1277
+ }
1278
+ const defaultFile = { version: 1 };
1279
+ return { path: filePath, exists: false, raw: null, file: defaultFile, hash: hashRaw(null) };
1280
+ }
1281
+ function saveExecApprovalsFile(filePath, file) {
1282
+ fs6.mkdirSync(path7.dirname(filePath), { recursive: true });
1283
+ const raw = JSON.stringify(file, null, 2) + "\n";
1284
+ fs6.writeFileSync(filePath, raw, { mode: 384 });
1285
+ return { path: filePath, exists: true, raw, file, hash: hashRaw(raw) };
1286
+ }
1287
+ function createExecApprovalsGetHandler(env) {
1288
+ return async (payload, client) => {
1289
+ const filePath = resolveExecApprovalsPath(env);
1290
+ const snapshot = loadExecApprovalsFile(filePath);
1291
+ const result = {
1292
+ id: payload.id,
1293
+ nodeId: payload.nodeId,
1294
+ ok: true,
1295
+ payloadJSON: JSON.stringify(snapshot)
1296
+ };
1297
+ await client.request("node.invoke.result", result);
1298
+ };
1299
+ }
1300
+ function createExecApprovalsSetHandler(env) {
1301
+ return async (payload, client) => {
1302
+ const filePath = resolveExecApprovalsPath(env);
1303
+ let params = {};
1304
+ if (payload.paramsJSON) {
1305
+ try {
1306
+ params = JSON.parse(payload.paramsJSON);
1307
+ } catch {
1308
+ await client.request("node.invoke.result", {
1309
+ id: payload.id,
1310
+ nodeId: payload.nodeId,
1311
+ ok: false,
1312
+ error: { code: "INVALID_PARAMS", message: "Invalid params" }
1313
+ });
1314
+ return;
1315
+ }
1316
+ }
1317
+ if (params.baseHash) {
1318
+ const current = loadExecApprovalsFile(filePath);
1319
+ if (current.hash !== params.baseHash) {
1320
+ await client.request("node.invoke.result", {
1321
+ id: payload.id,
1322
+ nodeId: payload.nodeId,
1323
+ ok: false,
1324
+ error: {
1325
+ code: "HASH_MISMATCH",
1326
+ message: "Exec approvals file was modified concurrently"
1327
+ }
1328
+ });
1329
+ return;
1330
+ }
1331
+ }
1332
+ const file = params.file ?? { version: 1 };
1333
+ const snapshot = saveExecApprovalsFile(filePath, file);
1334
+ const result = {
1335
+ id: payload.id,
1336
+ nodeId: payload.nodeId,
1337
+ ok: true,
1338
+ payloadJSON: JSON.stringify(snapshot)
1339
+ };
1340
+ await client.request("node.invoke.result", result);
1341
+ };
1342
+ }
1343
+
1344
+ // src/handlers/index.ts
1345
+ function registerAllHandlers(router, env) {
1346
+ const config = loadConfig(env);
1347
+ const workdir = config.device.workdir;
1348
+ router.register(
1349
+ "system.run",
1350
+ createSystemRunHandler(config.exec, workdir)
1351
+ );
1352
+ router.register("system.run.prepare", systemRunPrepareHandler);
1353
+ router.register(
1354
+ "system.info",
1355
+ (payload, client) => systemInfoHandler(payload, client, workdir)
1356
+ );
1357
+ router.register("system.which", systemWhichHandler);
1358
+ router.register(
1359
+ "system.execApprovals.get",
1360
+ createExecApprovalsGetHandler(env)
1361
+ );
1362
+ router.register(
1363
+ "system.execApprovals.set",
1364
+ createExecApprovalsSetHandler(env)
1365
+ );
1366
+ }
1367
+
1368
+ // src/cli/start.ts
1369
+ function registerStartCommand(program2) {
1370
+ program2.command("start").description("Start the node daemon (foreground)").action(async () => {
1371
+ try {
1372
+ const { router, stop } = await startNode();
1373
+ registerAllHandlers(router);
1374
+ console.log("NodeClaw running. Press Ctrl+C to stop.");
1375
+ const shutdown = () => {
1376
+ console.log("\nShutting down...");
1377
+ stop();
1378
+ process.exit(0);
1379
+ };
1380
+ process.on("SIGINT", shutdown);
1381
+ process.on("SIGTERM", shutdown);
1382
+ await new Promise(() => {
1383
+ });
1384
+ } catch (err) {
1385
+ console.error(
1386
+ err instanceof Error ? err.message : String(err)
1387
+ );
1388
+ process.exit(1);
1389
+ }
1390
+ });
1391
+ }
1392
+
1393
+ // src/cli/status.ts
1394
+ function registerStatusCommand(program2) {
1395
+ program2.command("status").description("Show connection and pairing status").action(() => {
1396
+ const config = loadConfig();
1397
+ const identityPath = resolveIdentityPath();
1398
+ const tokenStorePath = resolveTokenStorePath();
1399
+ let identity;
1400
+ try {
1401
+ identity = loadOrCreateIdentity(identityPath);
1402
+ } catch {
1403
+ console.log("Device identity: not initialized");
1404
+ return;
1405
+ }
1406
+ const token = loadDeviceAuthToken(
1407
+ tokenStorePath,
1408
+ identity.deviceId,
1409
+ "node"
1410
+ );
1411
+ console.log(`Gateway URL: ${config.gateway.url}`);
1412
+ console.log(`Device name: ${config.device.name || "(default)"}`);
1413
+ console.log(`Device ID: ${identity.deviceId}`);
1414
+ console.log(`Paired: ${token ? "yes" : "no"}`);
1415
+ if (config.device.workdir) {
1416
+ console.log(`Workdir: ${config.device.workdir}`);
1417
+ }
1418
+ });
1419
+ }
1420
+
1421
+ // src/cli/unpair.ts
1422
+ import fs7 from "fs";
1423
+ function registerUnpairCommand(program2) {
1424
+ program2.command("unpair").description("Remove pairing token and disconnect").option("--full", "Also delete device identity (generates new ID on next pair)").action((opts) => {
1425
+ const identityPath = resolveIdentityPath();
1426
+ const tokenStorePath = resolveTokenStorePath();
1427
+ try {
1428
+ const identity = loadOrCreateIdentity(identityPath);
1429
+ clearDeviceAuthToken(tokenStorePath, identity.deviceId, "node");
1430
+ console.log("Pairing token removed.");
1431
+ } catch {
1432
+ console.log("No pairing token found.");
1433
+ }
1434
+ if (opts.full) {
1435
+ try {
1436
+ if (fs7.existsSync(identityPath)) {
1437
+ fs7.unlinkSync(identityPath);
1438
+ console.log("Device identity deleted.");
1439
+ }
1440
+ } catch (err) {
1441
+ console.error(
1442
+ `Failed to delete identity: ${err instanceof Error ? err.message : String(err)}`
1443
+ );
1444
+ }
1445
+ }
1446
+ });
1447
+ }
1448
+
1449
+ // src/cli/version.ts
1450
+ function registerVersionCommand(program2) {
1451
+ program2.command("info").description("Show version and protocol information").action(() => {
1452
+ console.log(`NodeClaw v${VERSION}`);
1453
+ console.log(`Protocol: ${PROTOCOL_VERSION}`);
1454
+ console.log(`Node.js: ${process.version}`);
1455
+ console.log(`Platform: ${process.platform}`);
1456
+ console.log(`Arch: ${process.arch}`);
1457
+ });
1458
+ }
1459
+
1460
+ // src/cli/doctor.ts
1461
+ import fs8 from "fs";
1462
+ function runChecks() {
1463
+ const checks = [];
1464
+ const nodeVersion = parseInt(process.version.slice(1), 10);
1465
+ checks.push({
1466
+ name: "Node.js version",
1467
+ status: nodeVersion >= 20 ? "ok" : "fail",
1468
+ detail: `${process.version} ${nodeVersion >= 20 ? "(>= 20)" : "(requires >= 20)"}`
1469
+ });
1470
+ const baseDir = resolveBaseDir();
1471
+ checks.push({
1472
+ name: "Config directory",
1473
+ status: fs8.existsSync(baseDir) ? "ok" : "warn",
1474
+ detail: baseDir
1475
+ });
1476
+ const configPath = resolveConfigPath();
1477
+ if (fs8.existsSync(configPath)) {
1478
+ try {
1479
+ loadConfig();
1480
+ checks.push({ name: "Config file", status: "ok", detail: configPath });
1481
+ } catch {
1482
+ checks.push({
1483
+ name: "Config file",
1484
+ status: "fail",
1485
+ detail: `${configPath} (invalid)`
1486
+ });
1487
+ }
1488
+ } else {
1489
+ checks.push({
1490
+ name: "Config file",
1491
+ status: "warn",
1492
+ detail: `${configPath} (not found, using defaults)`
1493
+ });
1494
+ }
1495
+ const identityPath = resolveIdentityPath();
1496
+ if (fs8.existsSync(identityPath)) {
1497
+ try {
1498
+ const identity = loadOrCreateIdentity(identityPath);
1499
+ checks.push({
1500
+ name: "Device identity",
1501
+ status: "ok",
1502
+ detail: `${identity.deviceId.slice(0, 16)}...`
1503
+ });
1504
+ } catch {
1505
+ checks.push({
1506
+ name: "Device identity",
1507
+ status: "fail",
1508
+ detail: `${identityPath} (corrupt)`
1509
+ });
1510
+ }
1511
+ } else {
1512
+ checks.push({
1513
+ name: "Device identity",
1514
+ status: "warn",
1515
+ detail: "Not initialized (will be created on first pair)"
1516
+ });
1517
+ }
1518
+ const tokenStorePath = resolveTokenStorePath();
1519
+ try {
1520
+ const identity = loadOrCreateIdentity(identityPath);
1521
+ const token = loadDeviceAuthToken(
1522
+ tokenStorePath,
1523
+ identity.deviceId,
1524
+ "node"
1525
+ );
1526
+ checks.push({
1527
+ name: "Pairing token",
1528
+ status: token ? "ok" : "warn",
1529
+ detail: token ? "Stored" : "Not paired"
1530
+ });
1531
+ } catch {
1532
+ checks.push({
1533
+ name: "Pairing token",
1534
+ status: "warn",
1535
+ detail: "Cannot check (no identity)"
1536
+ });
1537
+ }
1538
+ const config = loadConfig();
1539
+ checks.push({
1540
+ name: "Gateway URL",
1541
+ status: config.gateway.url ? "ok" : "fail",
1542
+ detail: config.gateway.url || "(not configured)"
1543
+ });
1544
+ if (config.device.workdir) {
1545
+ const exists = fs8.existsSync(config.device.workdir);
1546
+ checks.push({
1547
+ name: "Workdir",
1548
+ status: exists ? "ok" : "fail",
1549
+ detail: `${config.device.workdir} ${exists ? "" : "(not found)"}`
1550
+ });
1551
+ } else {
1552
+ checks.push({
1553
+ name: "Workdir",
1554
+ status: "warn",
1555
+ detail: "Not configured (exec allowed anywhere)"
1556
+ });
1557
+ }
1558
+ return checks;
1559
+ }
1560
+ function registerDoctorCommand(program2) {
1561
+ program2.command("doctor").description("Check configuration, connectivity, and service status").action(() => {
1562
+ const checks = runChecks();
1563
+ let hasFailures = false;
1564
+ for (const check of checks) {
1565
+ const icon = check.status === "ok" ? "[OK] " : check.status === "warn" ? "[WARN]" : "[FAIL]";
1566
+ if (check.status === "fail") {
1567
+ hasFailures = true;
1568
+ }
1569
+ console.log(`${icon} ${check.name}: ${check.detail}`);
1570
+ }
1571
+ if (hasFailures) {
1572
+ process.exitCode = 1;
1573
+ }
1574
+ });
1575
+ }
1576
+
1577
+ // src/cli/install-service.ts
1578
+ import fs9 from "fs";
1579
+ import path8 from "path";
1580
+ import os7 from "os";
1581
+
1582
+ // src/service/systemd.ts
1583
+ import os5 from "os";
1584
+ import { execFileSync as execFileSync2 } from "child_process";
1585
+ function resolveNodeClawPath() {
1586
+ try {
1587
+ return execFileSync2("which", ["nodeclaw"], {
1588
+ encoding: "utf8",
1589
+ timeout: 5e3
1590
+ }).trim();
1591
+ } catch {
1592
+ return "/usr/local/bin/nodeclaw";
1593
+ }
1594
+ }
1595
+ function generateSystemdUnit(opts = {}) {
1596
+ const execPath = opts.execPath || resolveNodeClawPath();
1597
+ const user = opts.user || os5.userInfo().username;
1598
+ const workdir = opts.workdir || os5.homedir();
1599
+ return `[Unit]
1600
+ Description=NodeClaw - OpenClaw Node Client
1601
+ After=network-online.target
1602
+ Wants=network-online.target
1603
+
1604
+ [Service]
1605
+ Type=simple
1606
+ ExecStart=${execPath} start
1607
+ Restart=always
1608
+ RestartSec=5
1609
+ User=${user}
1610
+ WorkingDirectory=${workdir}
1611
+ Environment=NODE_ENV=production
1612
+
1613
+ [Install]
1614
+ WantedBy=multi-user.target
1615
+ `;
1616
+ }
1617
+
1618
+ // src/service/launchd.ts
1619
+ import os6 from "os";
1620
+ import { execFileSync as execFileSync3 } from "child_process";
1621
+ function resolveNodeClawPath2() {
1622
+ try {
1623
+ return execFileSync3("which", ["nodeclaw"], {
1624
+ encoding: "utf8",
1625
+ timeout: 5e3
1626
+ }).trim();
1627
+ } catch {
1628
+ return "/usr/local/bin/nodeclaw";
1629
+ }
1630
+ }
1631
+ function generateLaunchdPlist(opts = {}) {
1632
+ const execPath = opts.execPath || resolveNodeClawPath2();
1633
+ const workdir = opts.workdir || os6.homedir();
1634
+ const label = opts.label || "ai.nodeclaw";
1635
+ return `<?xml version="1.0" encoding="UTF-8"?>
1636
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1637
+ <plist version="1.0">
1638
+ <dict>
1639
+ <key>Label</key>
1640
+ <string>${label}</string>
1641
+ <key>ProgramArguments</key>
1642
+ <array>
1643
+ <string>${execPath}</string>
1644
+ <string>start</string>
1645
+ </array>
1646
+ <key>RunAtLoad</key>
1647
+ <true/>
1648
+ <key>KeepAlive</key>
1649
+ <true/>
1650
+ <key>WorkingDirectory</key>
1651
+ <string>${workdir}</string>
1652
+ <key>StandardOutPath</key>
1653
+ <string>/tmp/nodeclaw.stdout.log</string>
1654
+ <key>StandardErrorPath</key>
1655
+ <string>/tmp/nodeclaw.stderr.log</string>
1656
+ </dict>
1657
+ </plist>
1658
+ `;
1659
+ }
1660
+
1661
+ // src/cli/install-service.ts
1662
+ function registerInstallServiceCommand(program2) {
1663
+ program2.command("install-service").description("Install systemd (Linux) or launchd (macOS) service").option("--dry-run", "Print the service file without writing").action((opts) => {
1664
+ const config = loadConfig();
1665
+ const platform = process.platform;
1666
+ const workdir = config.device.workdir || os7.homedir();
1667
+ if (platform === "linux") {
1668
+ const unit = generateSystemdUnit({ workdir });
1669
+ if (opts.dryRun) {
1670
+ console.log(unit);
1671
+ return;
1672
+ }
1673
+ const unitPath = path8.join(
1674
+ os7.homedir(),
1675
+ ".config",
1676
+ "systemd",
1677
+ "user",
1678
+ "nodeclaw.service"
1679
+ );
1680
+ fs9.mkdirSync(path8.dirname(unitPath), { recursive: true });
1681
+ fs9.writeFileSync(unitPath, unit);
1682
+ console.log(`Service file written to ${unitPath}`);
1683
+ console.log("To enable: systemctl --user enable --now nodeclaw");
1684
+ } else if (platform === "darwin") {
1685
+ const plist = generateLaunchdPlist({ workdir });
1686
+ if (opts.dryRun) {
1687
+ console.log(plist);
1688
+ return;
1689
+ }
1690
+ const plistPath = path8.join(
1691
+ os7.homedir(),
1692
+ "Library",
1693
+ "LaunchAgents",
1694
+ "ai.nodeclaw.plist"
1695
+ );
1696
+ fs9.mkdirSync(path8.dirname(plistPath), { recursive: true });
1697
+ fs9.writeFileSync(plistPath, plist);
1698
+ console.log(`Plist written to ${plistPath}`);
1699
+ console.log("To load: launchctl load " + plistPath);
1700
+ } else {
1701
+ console.error(`Service installation not supported on ${platform}`);
1702
+ process.exit(1);
1703
+ }
1704
+ });
1705
+ program2.command("uninstall-service").description("Remove installed service").action(() => {
1706
+ const platform = process.platform;
1707
+ if (platform === "linux") {
1708
+ const unitPath = path8.join(
1709
+ os7.homedir(),
1710
+ ".config",
1711
+ "systemd",
1712
+ "user",
1713
+ "nodeclaw.service"
1714
+ );
1715
+ if (fs9.existsSync(unitPath)) {
1716
+ fs9.unlinkSync(unitPath);
1717
+ console.log("Service file removed.");
1718
+ console.log("Run: systemctl --user daemon-reload");
1719
+ } else {
1720
+ console.log("Service file not found.");
1721
+ }
1722
+ } else if (platform === "darwin") {
1723
+ const plistPath = path8.join(
1724
+ os7.homedir(),
1725
+ "Library",
1726
+ "LaunchAgents",
1727
+ "ai.nodeclaw.plist"
1728
+ );
1729
+ if (fs9.existsSync(plistPath)) {
1730
+ fs9.unlinkSync(plistPath);
1731
+ console.log("Plist removed.");
1732
+ console.log("Run: launchctl unload " + plistPath);
1733
+ } else {
1734
+ console.log("Plist not found.");
1735
+ }
1736
+ } else {
1737
+ console.error(`Service uninstall not supported on ${platform}`);
1738
+ process.exit(1);
1739
+ }
1740
+ });
1741
+ }
1742
+
1743
+ // src/cli.ts
1744
+ var program = new Command();
1745
+ program.name("nodeclaw").description("Minimal OpenClaw node protocol client").version(VERSION);
1746
+ registerPairCommand(program);
1747
+ registerStartCommand(program);
1748
+ registerStatusCommand(program);
1749
+ registerUnpairCommand(program);
1750
+ registerVersionCommand(program);
1751
+ registerDoctorCommand(program);
1752
+ registerInstallServiceCommand(program);
1753
+ program.parse();
1754
+ //# sourceMappingURL=cli.js.map