nervepay 1.3.7 → 1.3.9
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/nervepay-cli.js +137 -182
- package/package.json +1 -1
package/bin/nervepay-cli.js
CHANGED
|
@@ -392,6 +392,80 @@ program
|
|
|
392
392
|
}
|
|
393
393
|
});
|
|
394
394
|
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// WebSocket connect helper (used for initial connect + reconnect polling)
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
function attemptConnect(WebSocket, wsUrl, ctx) {
|
|
400
|
+
return new Promise((resolve, reject) => {
|
|
401
|
+
let settled = false;
|
|
402
|
+
const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
|
|
403
|
+
|
|
404
|
+
function settle(fn, value) {
|
|
405
|
+
if (settled) return;
|
|
406
|
+
settled = true;
|
|
407
|
+
try { ws.close(); } catch {}
|
|
408
|
+
fn(value);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const timer = setTimeout(() => settle(reject, new Error('Connect timeout')), 15000);
|
|
412
|
+
|
|
413
|
+
ws.on('error', (e) => settle(reject, e));
|
|
414
|
+
ws.on('close', () => setTimeout(() => settle(reject, new Error('Connection closed')), 150));
|
|
415
|
+
|
|
416
|
+
ws.on('message', async (data) => {
|
|
417
|
+
try {
|
|
418
|
+
const frame = JSON.parse(data.toString());
|
|
419
|
+
|
|
420
|
+
// Challenge → sign and connect
|
|
421
|
+
if (frame.event === 'connect.challenge') {
|
|
422
|
+
const nonce = frame.payload?.nonce || '';
|
|
423
|
+
const signedAtMs = Date.now();
|
|
424
|
+
const role = 'operator';
|
|
425
|
+
const scopes = ['operator.read', 'operator.write'];
|
|
426
|
+
|
|
427
|
+
const payload = [
|
|
428
|
+
'v2', ctx.deviceId, 'node-host', 'node', role,
|
|
429
|
+
scopes.join(','), String(signedAtMs), ctx.gatewayToken, nonce,
|
|
430
|
+
].join('|');
|
|
431
|
+
|
|
432
|
+
const payloadBytes = new TextEncoder().encode(payload);
|
|
433
|
+
const signatureB64 = await ctx.signRawBytes(ctx.privateKey, payloadBytes);
|
|
434
|
+
const signatureB64Url = signatureB64
|
|
435
|
+
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
436
|
+
|
|
437
|
+
const connectId = crypto.randomUUID();
|
|
438
|
+
ws.send(JSON.stringify({
|
|
439
|
+
type: 'req', id: connectId, method: 'connect',
|
|
440
|
+
params: {
|
|
441
|
+
role, minProtocol: 3, maxProtocol: 3, scopes,
|
|
442
|
+
client: { id: 'node-host', version: ctx.VERSION, platform: process.platform, mode: 'node' },
|
|
443
|
+
device: { id: ctx.deviceId, publicKey: ctx.pubKeyB64Url, signature: signatureB64Url, signedAt: signedAtMs, nonce },
|
|
444
|
+
auth: { token: ctx.gatewayToken },
|
|
445
|
+
},
|
|
446
|
+
}));
|
|
447
|
+
|
|
448
|
+
// Wait for response
|
|
449
|
+
ws.once('message', (resData) => {
|
|
450
|
+
clearTimeout(timer);
|
|
451
|
+
try {
|
|
452
|
+
const res = JSON.parse(resData.toString());
|
|
453
|
+
if (res.ok) {
|
|
454
|
+
const deviceToken = res.payload?.auth?.deviceToken || '';
|
|
455
|
+
settle(resolve, { token: deviceToken, payload: res.payload });
|
|
456
|
+
} else if (res.error?.code === 'NOT_PAIRED') {
|
|
457
|
+
settle(reject, new Error('NOT_PAIRED'));
|
|
458
|
+
} else {
|
|
459
|
+
settle(reject, new Error(res.error?.message || 'Connect failed'));
|
|
460
|
+
}
|
|
461
|
+
} catch { settle(reject, new Error('Invalid response')); }
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
} catch {}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
395
469
|
// ---------------------------------------------------------------------------
|
|
396
470
|
// Device node pairing
|
|
397
471
|
// ---------------------------------------------------------------------------
|
|
@@ -436,203 +510,84 @@ async function deviceNodePairing(options) {
|
|
|
436
510
|
logInfo('Connecting...');
|
|
437
511
|
|
|
438
512
|
const WebSocket = (await import('ws')).default;
|
|
439
|
-
const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
|
|
440
|
-
const timeoutMs = parseInt(options.timeout, 10) * 1000;
|
|
441
|
-
|
|
442
|
-
// Pre-compute crypto outside WS handler to avoid async race conditions
|
|
443
513
|
const pubKeyBytes = decodeBase58(publicKeyBase58);
|
|
444
|
-
// OpenClaw expects base64url encoding (no padding) for public key
|
|
445
514
|
const pubKeyB64Url = Buffer.from(pubKeyBytes).toString('base64')
|
|
446
515
|
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
447
516
|
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
let connectId = null;
|
|
451
|
-
let pairId = null;
|
|
452
|
-
let requestId = null;
|
|
453
|
-
let lastError = null;
|
|
454
|
-
|
|
455
|
-
function settle(fn, value) {
|
|
456
|
-
if (settled) return;
|
|
457
|
-
settled = true;
|
|
458
|
-
clearTimeout(timer);
|
|
459
|
-
fn(value);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const timer = setTimeout(() => {
|
|
463
|
-
ws.close();
|
|
464
|
-
settle(reject, new Error(`Approval timed out after ${options.timeout}s`));
|
|
465
|
-
}, timeoutMs + 15000);
|
|
466
|
-
|
|
467
|
-
ws.on('error', (e) => settle(reject, new Error(`Connection failed: ${e.message}`)));
|
|
517
|
+
const ctx = { deviceId, pubKeyB64Url, privateKey, gatewayToken, signRawBytes, VERSION };
|
|
518
|
+
const timeoutMs = parseInt(options.timeout, 10) * 1000;
|
|
468
519
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
520
|
+
// First attempt
|
|
521
|
+
let deviceToken = '';
|
|
522
|
+
try {
|
|
523
|
+
const result = await attemptConnect(WebSocket, wsUrl, ctx);
|
|
524
|
+
deviceToken = result.token;
|
|
525
|
+
logOk('Connected (device already paired)');
|
|
526
|
+
} catch (e) {
|
|
527
|
+
if (e.message !== 'NOT_PAIRED') throw e;
|
|
475
528
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const nonce = frame.payload?.nonce || '';
|
|
484
|
-
logOk('Challenge received');
|
|
485
|
-
|
|
486
|
-
const signedAtMs = Date.now();
|
|
487
|
-
const role = 'operator';
|
|
488
|
-
const scopes = ['operator.read', 'operator.write'];
|
|
489
|
-
|
|
490
|
-
// Build structured payload per OpenClaw device-auth protocol (v2)
|
|
491
|
-
// Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
|
492
|
-
const payload = [
|
|
493
|
-
'v2',
|
|
494
|
-
deviceId,
|
|
495
|
-
'node-host',
|
|
496
|
-
'node',
|
|
497
|
-
role,
|
|
498
|
-
scopes.join(','),
|
|
499
|
-
String(signedAtMs),
|
|
500
|
-
gatewayToken,
|
|
501
|
-
nonce,
|
|
502
|
-
].join('|');
|
|
503
|
-
|
|
504
|
-
logDebug('Auth payload:', payload);
|
|
505
|
-
|
|
506
|
-
const payloadBytes = new TextEncoder().encode(payload);
|
|
507
|
-
const signatureB64 = await signRawBytes(privateKey, payloadBytes);
|
|
508
|
-
// Convert to base64url (no padding)
|
|
509
|
-
const signatureB64Url = signatureB64
|
|
510
|
-
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
511
|
-
|
|
512
|
-
connectId = crypto.randomUUID();
|
|
513
|
-
ws.send(JSON.stringify({
|
|
514
|
-
type: 'req',
|
|
515
|
-
id: connectId,
|
|
516
|
-
method: 'connect',
|
|
517
|
-
params: {
|
|
518
|
-
role,
|
|
519
|
-
minProtocol: 3,
|
|
520
|
-
maxProtocol: 3,
|
|
521
|
-
scopes,
|
|
522
|
-
client: {
|
|
523
|
-
id: 'node-host',
|
|
524
|
-
version: VERSION,
|
|
525
|
-
platform: process.platform,
|
|
526
|
-
mode: 'node',
|
|
527
|
-
},
|
|
528
|
-
device: {
|
|
529
|
-
id: deviceId,
|
|
530
|
-
publicKey: pubKeyB64Url,
|
|
531
|
-
signature: signatureB64Url,
|
|
532
|
-
signedAt: signedAtMs,
|
|
533
|
-
nonce,
|
|
534
|
-
},
|
|
535
|
-
auth: {
|
|
536
|
-
token: gatewayToken,
|
|
537
|
-
},
|
|
538
|
-
},
|
|
539
|
-
}));
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
529
|
+
// Device needs pairing approval — poll reconnect until approved
|
|
530
|
+
logOk('Pairing request created');
|
|
531
|
+
console.log();
|
|
532
|
+
logInfo('Waiting for gateway owner approval...');
|
|
533
|
+
log(dim(' Approve on gateway: openclaw devices approve'));
|
|
534
|
+
log(dim(` Timeout: ${options.timeout}s`));
|
|
535
|
+
console.log();
|
|
542
536
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
logOk('Connected to gateway');
|
|
547
|
-
|
|
548
|
-
pairId = crypto.randomUUID();
|
|
549
|
-
ws.send(JSON.stringify({
|
|
550
|
-
type: 'req',
|
|
551
|
-
id: pairId,
|
|
552
|
-
method: 'node.pair.request',
|
|
553
|
-
params: {
|
|
554
|
-
displayName: `NervePay (${agentDid.split(':').pop().slice(0, 8)})`,
|
|
555
|
-
platform: process.platform,
|
|
556
|
-
},
|
|
557
|
-
}));
|
|
558
|
-
} else {
|
|
559
|
-
lastError = formatFrameError('Connect rejected', frame);
|
|
560
|
-
logErr(lastError);
|
|
561
|
-
ws.close();
|
|
562
|
-
settle(reject, new Error(lastError));
|
|
563
|
-
}
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
537
|
+
const pollInterval = 5000;
|
|
538
|
+
const maxAttempts = Math.ceil(timeoutMs / pollInterval);
|
|
539
|
+
let approved = false;
|
|
566
540
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
requestId = frame.payload?.requestId || '';
|
|
571
|
-
logOk('Pairing request submitted');
|
|
572
|
-
console.log();
|
|
573
|
-
logInfo('Waiting for gateway owner approval...');
|
|
574
|
-
log(dim(`Approve: openclaw devices approve ${requestId}`));
|
|
575
|
-
log(dim(`Timeout: ${options.timeout}s`));
|
|
576
|
-
console.log();
|
|
577
|
-
} else {
|
|
578
|
-
lastError = formatFrameError('Pair rejected', frame);
|
|
579
|
-
logErr(lastError);
|
|
580
|
-
ws.close();
|
|
581
|
-
settle(reject, new Error(lastError));
|
|
582
|
-
}
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
541
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
542
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
543
|
+
process.stdout.write(dim('.'));
|
|
585
544
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
nodeId: frame.payload?.nodeId || '',
|
|
597
|
-
requestId,
|
|
598
|
-
});
|
|
599
|
-
} else {
|
|
600
|
-
ws.close();
|
|
601
|
-
settle(reject, new Error(decision === 'rejected'
|
|
602
|
-
? 'Pairing rejected by gateway owner'
|
|
603
|
-
: `Unexpected decision: ${decision}`));
|
|
604
|
-
}
|
|
605
|
-
return;
|
|
545
|
+
try {
|
|
546
|
+
const result = await attemptConnect(WebSocket, wsUrl, ctx);
|
|
547
|
+
deviceToken = result.token;
|
|
548
|
+
approved = true;
|
|
549
|
+
console.log();
|
|
550
|
+
break;
|
|
551
|
+
} catch (retryErr) {
|
|
552
|
+
if (retryErr.message !== 'NOT_PAIRED') {
|
|
553
|
+
console.log();
|
|
554
|
+
throw retryErr;
|
|
606
555
|
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!approved) die(`Approval timed out after ${options.timeout}s`);
|
|
559
|
+
}
|
|
607
560
|
|
|
608
|
-
|
|
609
|
-
logDebug('Unhandled frame:', JSON.stringify(frame).slice(0, 200));
|
|
610
|
-
} catch { /* non-JSON */ }
|
|
611
|
-
});
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
logOk(bold('Approved!'));
|
|
561
|
+
logOk(bold('Paired!'));
|
|
615
562
|
console.log();
|
|
616
|
-
logInfo('Saving to NervePay...');
|
|
617
563
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
564
|
+
if (deviceToken) {
|
|
565
|
+
logInfo('Saving to NervePay...');
|
|
566
|
+
const client = new NervePayClient({
|
|
567
|
+
apiUrl: options.apiUrl || apiUrl,
|
|
568
|
+
agentDid,
|
|
569
|
+
privateKey,
|
|
570
|
+
});
|
|
623
571
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
572
|
+
const apiResult = await gateway.completeDevicePairing(client, {
|
|
573
|
+
device_token: deviceToken,
|
|
574
|
+
device_id: deviceId,
|
|
575
|
+
gateway_url: gatewayUrl,
|
|
576
|
+
gateway_name: options.name,
|
|
577
|
+
});
|
|
630
578
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
579
|
+
divider();
|
|
580
|
+
logOk(bold('Gateway paired'));
|
|
581
|
+
field(' Gateway ID', apiResult.gateway_id);
|
|
582
|
+
field(' Device ID', deviceId.slice(0, 16) + '...');
|
|
583
|
+
field(' Agent DID', agentDid);
|
|
584
|
+
} else {
|
|
585
|
+
divider();
|
|
586
|
+
logOk(bold('Device approved by gateway'));
|
|
587
|
+
field(' Device ID', deviceId.slice(0, 16) + '...');
|
|
588
|
+
field(' Agent DID', agentDid);
|
|
589
|
+
log(warn(' No device token received — gateway may not issue tokens for this role'));
|
|
590
|
+
}
|
|
636
591
|
console.log();
|
|
637
592
|
log(dim('View in dashboard:'), info('Mission Control > Task Board'));
|
|
638
593
|
console.log();
|