@notis_ai/cli 0.2.11 → 0.2.12
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/package.json +1 -1
- package/skills/notis-cli/SKILL.md +1 -1
- package/src/runtime/oauth.js +37 -8
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@ Use the registry-resolved published npm package everywhere:
|
|
|
29
29
|
|
|
30
30
|
- `npx --package @notis_ai/cli@latest -- notis ...`
|
|
31
31
|
|
|
32
|
-
Always use this NPX command form so the agent runs the current published CLI. In hosted shells, the CLI is pre-authenticated through `NOTIS_JWT`. On a local machine the CLI
|
|
32
|
+
Always use this NPX command form so the agent runs the current published CLI. In hosted shells, the CLI is pre-authenticated through `NOTIS_JWT`. On a local machine the CLI holds its own OAuth grant: `notis login` authorizes one in the browser, and signing in to the Notis desktop app authorizes one automatically for that account. Either way the grant belongs to the CLI, which refreshes it without the desktop app running.
|
|
33
33
|
|
|
34
34
|
This `notis-cli` skill is delivered through normal Notis skill sync for the signed-in user, alongside other curated skills.
|
|
35
35
|
|
package/src/runtime/oauth.js
CHANGED
|
@@ -43,6 +43,7 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
|
|
|
43
43
|
// still have to sign up, verify an email, and consent before pasting the code.
|
|
44
44
|
const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
|
|
45
45
|
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
|
|
46
|
+
const RESPONSE_FLUSH_GRACE_MS = 2_000;
|
|
46
47
|
|
|
47
48
|
function oauthError(code, message, hints = null, details = {}) {
|
|
48
49
|
return new CliError({
|
|
@@ -461,11 +462,22 @@ export async function createLoopbackReceiver({
|
|
|
461
462
|
let rejectCode;
|
|
462
463
|
let timeout;
|
|
463
464
|
let pendingResponse = null;
|
|
465
|
+
// Browsers routinely park speculative connections that never send a request.
|
|
466
|
+
// `server.close()` waits for every socket it accepted, so the sockets have to
|
|
467
|
+
// be tracked and dropped by hand or a finished login would keep waiting.
|
|
468
|
+
const sockets = new Set();
|
|
469
|
+
let responseFlushed = Promise.resolve();
|
|
464
470
|
const result = new Promise((resolve, reject) => {
|
|
465
471
|
resolveCode = resolve;
|
|
466
472
|
rejectCode = reject;
|
|
467
473
|
});
|
|
468
474
|
|
|
475
|
+
const endResponse = (response, body) => {
|
|
476
|
+
responseFlushed = new Promise((resolve) => {
|
|
477
|
+
response.end(body, resolve);
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
|
|
469
481
|
const server = createServer((request, response) => {
|
|
470
482
|
const address = server.address();
|
|
471
483
|
const expectedHost = address && typeof address === 'object'
|
|
@@ -478,18 +490,18 @@ export async function createLoopbackReceiver({
|
|
|
478
490
|
|
|
479
491
|
if (request.headers.host !== expectedHost) {
|
|
480
492
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
481
|
-
response
|
|
493
|
+
endResponse(response, callbackHtml('Invalid callback', 'The callback host was not accepted.'));
|
|
482
494
|
return;
|
|
483
495
|
}
|
|
484
496
|
const url = new URL(request.url || '/', `http://${expectedHost}`);
|
|
485
497
|
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
486
498
|
response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
487
|
-
response
|
|
499
|
+
endResponse(response, callbackHtml('Not found', 'This callback path does not exist.'));
|
|
488
500
|
return;
|
|
489
501
|
}
|
|
490
502
|
if (consumed) {
|
|
491
503
|
response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
492
|
-
response
|
|
504
|
+
endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
|
|
493
505
|
return;
|
|
494
506
|
}
|
|
495
507
|
consumed = true;
|
|
@@ -499,14 +511,14 @@ export async function createLoopbackReceiver({
|
|
|
499
511
|
const error = url.searchParams.get('error');
|
|
500
512
|
if (!stateMatches(state, returnedState)) {
|
|
501
513
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
502
|
-
response
|
|
514
|
+
endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
|
|
503
515
|
rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
|
|
504
516
|
return;
|
|
505
517
|
}
|
|
506
518
|
if (error || !code) {
|
|
507
519
|
const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
|
|
508
520
|
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
509
|
-
response
|
|
521
|
+
endResponse(response, callbackHtml('Authorization not completed', description));
|
|
510
522
|
rejectCode(oauthError(error || 'oauth_code_missing', description));
|
|
511
523
|
return;
|
|
512
524
|
}
|
|
@@ -517,6 +529,11 @@ export async function createLoopbackReceiver({
|
|
|
517
529
|
resolveCode(code);
|
|
518
530
|
});
|
|
519
531
|
|
|
532
|
+
server.on('connection', (socket) => {
|
|
533
|
+
sockets.add(socket);
|
|
534
|
+
socket.on('close', () => sockets.delete(socket));
|
|
535
|
+
});
|
|
536
|
+
|
|
520
537
|
await new Promise((resolve, reject) => {
|
|
521
538
|
server.once('error', reject);
|
|
522
539
|
server.listen(0, '127.0.0.1', () => {
|
|
@@ -539,12 +556,24 @@ export async function createLoopbackReceiver({
|
|
|
539
556
|
clearTimeout(timeout);
|
|
540
557
|
if (pendingResponse && !pendingResponse.writableEnded) {
|
|
541
558
|
pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
542
|
-
pendingResponse
|
|
559
|
+
endResponse(pendingResponse, callbackHtml(
|
|
543
560
|
'Authorization did not finish',
|
|
544
561
|
'Return to the terminal for details, then retry sign in.',
|
|
545
562
|
));
|
|
546
563
|
pendingResponse = null;
|
|
547
564
|
}
|
|
565
|
+
// The browser answer is already written; wait only for the kernel to take
|
|
566
|
+
// it so tearing the socket down cannot truncate the connected page.
|
|
567
|
+
await Promise.race([
|
|
568
|
+
responseFlushed,
|
|
569
|
+
new Promise((resolve) => { setTimeout(resolve, RESPONSE_FLUSH_GRACE_MS).unref?.(); }),
|
|
570
|
+
]);
|
|
571
|
+
// Chrome parks a speculative connection next to the one that carried the
|
|
572
|
+
// callback. It never sends a request, so Node counts it as active and
|
|
573
|
+
// `server.close()` waits for a socket only the browser will ever release:
|
|
574
|
+
// a login that already succeeded would sit in the terminal for minutes.
|
|
575
|
+
for (const socket of sockets) socket.destroy();
|
|
576
|
+
sockets.clear();
|
|
548
577
|
if (!server.listening) return;
|
|
549
578
|
await new Promise((resolve) => server.close(resolve));
|
|
550
579
|
};
|
|
@@ -563,13 +592,13 @@ export async function createLoopbackReceiver({
|
|
|
563
592
|
'Content-Type': 'text/html; charset=utf-8',
|
|
564
593
|
Location: connectedUrl.toString(),
|
|
565
594
|
});
|
|
566
|
-
pendingResponse
|
|
595
|
+
endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
|
|
567
596
|
pendingResponse = null;
|
|
568
597
|
},
|
|
569
598
|
fail: () => {
|
|
570
599
|
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
571
600
|
pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
572
|
-
pendingResponse
|
|
601
|
+
endResponse(pendingResponse, callbackHtml(
|
|
573
602
|
'Authorization failed',
|
|
574
603
|
'The CLI could not finish signing in. Return to the terminal for details.',
|
|
575
604
|
));
|