nervepay 1.3.6 → 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 +145 -183
- package/package.json +1 -1
package/bin/nervepay-cli.js
CHANGED
|
@@ -380,7 +380,7 @@ program
|
|
|
380
380
|
.option('--code <code>', 'Pairing code from dashboard (legacy flow)')
|
|
381
381
|
.option('--api-url <url>', 'NervePay API URL', DEFAULT_API_URL)
|
|
382
382
|
.option('--gateway-url <url>', 'Gateway URL')
|
|
383
|
-
.option('--gateway-token <token>', 'Gateway token
|
|
383
|
+
.option('--gateway-token <token>', 'Gateway auth token')
|
|
384
384
|
.option('--name <name>', 'Gateway display name', 'OpenClaw Gateway')
|
|
385
385
|
.option('--timeout <seconds>', 'Approval timeout in seconds', '300')
|
|
386
386
|
.action(async (options) => {
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -412,13 +486,17 @@ async function deviceNodePairing(options) {
|
|
|
412
486
|
logOk('Agent loaded');
|
|
413
487
|
field(' DID', agentDid);
|
|
414
488
|
|
|
415
|
-
// Resolve gateway URL
|
|
489
|
+
// Resolve gateway URL and token
|
|
416
490
|
let gatewayUrl = options.gatewayUrl;
|
|
417
|
-
|
|
491
|
+
let gatewayToken = options.gatewayToken;
|
|
492
|
+
if (!gatewayUrl || !gatewayToken) {
|
|
418
493
|
const oc = await readOpenClawConfig();
|
|
419
|
-
|
|
494
|
+
const gwConf = extractGatewayConfig(oc);
|
|
495
|
+
gatewayUrl = gatewayUrl || gwConf.url;
|
|
496
|
+
gatewayToken = gatewayToken || gwConf.token;
|
|
420
497
|
}
|
|
421
498
|
if (!gatewayUrl) die('No gateway URL found', 'Use: nervepay pair --gateway-url <url>');
|
|
499
|
+
if (!gatewayToken) die('No gateway token found', 'Use: nervepay pair --gateway-token <token>');
|
|
422
500
|
|
|
423
501
|
const wsUrl = httpToWs(gatewayUrl);
|
|
424
502
|
field(' Gateway', wsUrl);
|
|
@@ -432,200 +510,84 @@ async function deviceNodePairing(options) {
|
|
|
432
510
|
logInfo('Connecting...');
|
|
433
511
|
|
|
434
512
|
const WebSocket = (await import('ws')).default;
|
|
435
|
-
const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
|
|
436
|
-
const timeoutMs = parseInt(options.timeout, 10) * 1000;
|
|
437
|
-
|
|
438
|
-
// Pre-compute crypto outside WS handler to avoid async race conditions
|
|
439
513
|
const pubKeyBytes = decodeBase58(publicKeyBase58);
|
|
440
|
-
// OpenClaw expects base64url encoding (no padding) for public key
|
|
441
514
|
const pubKeyB64Url = Buffer.from(pubKeyBytes).toString('base64')
|
|
442
515
|
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
443
516
|
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
let connectId = null;
|
|
447
|
-
let pairId = null;
|
|
448
|
-
let requestId = null;
|
|
449
|
-
let lastError = null;
|
|
450
|
-
|
|
451
|
-
function settle(fn, value) {
|
|
452
|
-
if (settled) return;
|
|
453
|
-
settled = true;
|
|
454
|
-
clearTimeout(timer);
|
|
455
|
-
fn(value);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const timer = setTimeout(() => {
|
|
459
|
-
ws.close();
|
|
460
|
-
settle(reject, new Error(`Approval timed out after ${options.timeout}s`));
|
|
461
|
-
}, timeoutMs + 15000);
|
|
462
|
-
|
|
463
|
-
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;
|
|
464
519
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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;
|
|
471
528
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const nonce = frame.payload?.nonce || '';
|
|
480
|
-
logOk('Challenge received');
|
|
481
|
-
|
|
482
|
-
const signedAtMs = Date.now();
|
|
483
|
-
const role = 'operator';
|
|
484
|
-
const scopes = ['operator.read', 'operator.write'];
|
|
485
|
-
|
|
486
|
-
// Build structured payload per OpenClaw device-auth protocol (v2)
|
|
487
|
-
// Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
|
488
|
-
const payload = [
|
|
489
|
-
'v2',
|
|
490
|
-
deviceId,
|
|
491
|
-
'node-host',
|
|
492
|
-
'node',
|
|
493
|
-
role,
|
|
494
|
-
scopes.join(','),
|
|
495
|
-
String(signedAtMs),
|
|
496
|
-
'', // token (empty for initial connect)
|
|
497
|
-
nonce,
|
|
498
|
-
].join('|');
|
|
499
|
-
|
|
500
|
-
logDebug('Auth payload:', payload);
|
|
501
|
-
|
|
502
|
-
const payloadBytes = new TextEncoder().encode(payload);
|
|
503
|
-
const signatureB64 = await signRawBytes(privateKey, payloadBytes);
|
|
504
|
-
// Convert to base64url (no padding)
|
|
505
|
-
const signatureB64Url = signatureB64
|
|
506
|
-
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
507
|
-
|
|
508
|
-
connectId = crypto.randomUUID();
|
|
509
|
-
ws.send(JSON.stringify({
|
|
510
|
-
type: 'req',
|
|
511
|
-
id: connectId,
|
|
512
|
-
method: 'connect',
|
|
513
|
-
params: {
|
|
514
|
-
role,
|
|
515
|
-
minProtocol: 3,
|
|
516
|
-
maxProtocol: 3,
|
|
517
|
-
scopes,
|
|
518
|
-
client: {
|
|
519
|
-
id: 'node-host',
|
|
520
|
-
version: VERSION,
|
|
521
|
-
platform: process.platform,
|
|
522
|
-
mode: 'node',
|
|
523
|
-
},
|
|
524
|
-
device: {
|
|
525
|
-
id: deviceId,
|
|
526
|
-
publicKey: pubKeyB64Url,
|
|
527
|
-
signature: signatureB64Url,
|
|
528
|
-
signedAt: signedAtMs,
|
|
529
|
-
nonce,
|
|
530
|
-
},
|
|
531
|
-
},
|
|
532
|
-
}));
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
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();
|
|
535
536
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
logOk('Connected to gateway');
|
|
540
|
-
|
|
541
|
-
pairId = crypto.randomUUID();
|
|
542
|
-
ws.send(JSON.stringify({
|
|
543
|
-
type: 'req',
|
|
544
|
-
id: pairId,
|
|
545
|
-
method: 'node.pair.request',
|
|
546
|
-
params: {
|
|
547
|
-
displayName: `NervePay (${agentDid.split(':').pop().slice(0, 8)})`,
|
|
548
|
-
platform: process.platform,
|
|
549
|
-
},
|
|
550
|
-
}));
|
|
551
|
-
} else {
|
|
552
|
-
lastError = formatFrameError('Connect rejected', frame);
|
|
553
|
-
logErr(lastError);
|
|
554
|
-
ws.close();
|
|
555
|
-
settle(reject, new Error(lastError));
|
|
556
|
-
}
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
537
|
+
const pollInterval = 5000;
|
|
538
|
+
const maxAttempts = Math.ceil(timeoutMs / pollInterval);
|
|
539
|
+
let approved = false;
|
|
559
540
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
requestId = frame.payload?.requestId || '';
|
|
564
|
-
logOk('Pairing request submitted');
|
|
565
|
-
console.log();
|
|
566
|
-
logInfo('Waiting for gateway owner approval...');
|
|
567
|
-
log(dim(`Approve: openclaw devices approve ${requestId}`));
|
|
568
|
-
log(dim(`Timeout: ${options.timeout}s`));
|
|
569
|
-
console.log();
|
|
570
|
-
} else {
|
|
571
|
-
lastError = formatFrameError('Pair rejected', frame);
|
|
572
|
-
logErr(lastError);
|
|
573
|
-
ws.close();
|
|
574
|
-
settle(reject, new Error(lastError));
|
|
575
|
-
}
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
541
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
542
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
543
|
+
process.stdout.write(dim('.'));
|
|
578
544
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
nodeId: frame.payload?.nodeId || '',
|
|
590
|
-
requestId,
|
|
591
|
-
});
|
|
592
|
-
} else {
|
|
593
|
-
ws.close();
|
|
594
|
-
settle(reject, new Error(decision === 'rejected'
|
|
595
|
-
? 'Pairing rejected by gateway owner'
|
|
596
|
-
: `Unexpected decision: ${decision}`));
|
|
597
|
-
}
|
|
598
|
-
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;
|
|
599
555
|
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!approved) die(`Approval timed out after ${options.timeout}s`);
|
|
559
|
+
}
|
|
600
560
|
|
|
601
|
-
|
|
602
|
-
logDebug('Unhandled frame:', JSON.stringify(frame).slice(0, 200));
|
|
603
|
-
} catch { /* non-JSON */ }
|
|
604
|
-
});
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
logOk(bold('Approved!'));
|
|
561
|
+
logOk(bold('Paired!'));
|
|
608
562
|
console.log();
|
|
609
|
-
logInfo('Saving to NervePay...');
|
|
610
563
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
564
|
+
if (deviceToken) {
|
|
565
|
+
logInfo('Saving to NervePay...');
|
|
566
|
+
const client = new NervePayClient({
|
|
567
|
+
apiUrl: options.apiUrl || apiUrl,
|
|
568
|
+
agentDid,
|
|
569
|
+
privateKey,
|
|
570
|
+
});
|
|
616
571
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
+
});
|
|
623
578
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
+
}
|
|
629
591
|
console.log();
|
|
630
592
|
log(dim('View in dashboard:'), info('Mission Control > Task Board'));
|
|
631
593
|
console.log();
|