@nexrall/code-core 1.4.22 → 1.4.24

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.
@@ -7,6 +7,7 @@ exports.API_BASE = void 0;
7
7
  exports.chooseFinalContent = chooseFinalContent;
8
8
  exports.streamChat = streamChat;
9
9
  exports.cancelTurn = cancelTurn;
10
+ exports.revokeRefreshToken = revokeRefreshToken;
10
11
  exports.getBalance = getBalance;
11
12
  exports.exchangeVscodeCode = exchangeVscodeCode;
12
13
  exports.login = login;
@@ -115,6 +116,91 @@ function authHeaders() {
115
116
  Authorization: `Bearer ${token}`,
116
117
  };
117
118
  }
119
+ // ─── Silent token refresh ─────────────────────────────────────────────────────
120
+ /**
121
+ * In-flight refresh, shared by every caller that discovers an expired token at the
122
+ * same moment.
123
+ *
124
+ * This is a correctness requirement, not a micro-optimisation. Refresh tokens
125
+ * ROTATE: presenting one invalidates it and returns a replacement, and presenting
126
+ * a spent one is treated by the backend as theft (`reuse_detected`) — it revokes
127
+ * the entire token family and forces a real re-login. A long turn can easily have
128
+ * several requests in flight (the stream, a cancel, a balance poll), so without
129
+ * this they would each present the SAME refresh token and all but the first would
130
+ * look exactly like an attack. De-duplicating means one rotation, one winner,
131
+ * everyone else awaiting the same promise.
132
+ */
133
+ let _refreshInFlight = null;
134
+ /**
135
+ * Exchange the stored refresh token for a fresh access token. Resolves true when
136
+ * the tokens on disk were updated, false when refreshing isn't possible (no
137
+ * refresh token stored, or the server rejected it).
138
+ *
139
+ * Deliberately never throws: every call site treats `false` as "carry on and let
140
+ * the original 401/403 surface", which is the correct fallback in all cases.
141
+ */
142
+ async function refreshAccessToken() {
143
+ if (_refreshInFlight)
144
+ return _refreshInFlight;
145
+ _refreshInFlight = (async () => {
146
+ const refreshToken = (0, index_1.getRefreshToken)();
147
+ // No refresh token: a NEXRALL_TOKEN/CI credential, or a config written before
148
+ // this field existed. Nothing to do — the caller falls back to the auth error.
149
+ if (!refreshToken)
150
+ return false;
151
+ try {
152
+ const res = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/auth/refresh`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ refreshToken }),
156
+ });
157
+ if (!res.ok)
158
+ return false; // 401 = invalid/expired/reused → a real re-login is needed
159
+ const data = (await res.json());
160
+ if (!data.token)
161
+ return false;
162
+ (0, index_1.updateTokens)(data.token, data.refreshToken);
163
+ return true;
164
+ }
165
+ catch {
166
+ // Network failure. Not an auth problem — report failure so the caller's own
167
+ // retry/backoff machinery handles it as the transport error it is.
168
+ return false;
169
+ }
170
+ })();
171
+ try {
172
+ return await _refreshInFlight;
173
+ }
174
+ finally {
175
+ _refreshInFlight = null;
176
+ }
177
+ }
178
+ /**
179
+ * Is this response an EXPIRED-CREDENTIAL failure that a refresh could fix?
180
+ *
181
+ * The backend's auth middleware answers 401 for a missing token and 403 for one
182
+ * that is invalid or expired. 403 is ambiguous — Anthropic's upstream
183
+ * "forbidden" passthrough uses it too — so the body has to disambiguate: the
184
+ * upstream shape is `{error:{type:'forbidden'}}` (a nested object), while the auth
185
+ * middleware sends a flat `{error:'Invalid or expired token'}` string. Refreshing
186
+ * on the upstream shape would be harmless but pointless; getting it backwards and
187
+ * NOT refreshing on the auth shape is the bug being fixed, so the test suite pins
188
+ * both directions.
189
+ */
190
+ function isExpiredTokenResponse(status, body) {
191
+ if (status !== 401 && status !== 403)
192
+ return false;
193
+ try {
194
+ const parsed = JSON.parse(body);
195
+ // Nested object → Anthropic's upstream passthrough, not our auth layer.
196
+ if (parsed?.error && typeof parsed.error === 'object')
197
+ return false;
198
+ return typeof parsed?.error === 'string' && /token|auth|expired/i.test(parsed.error);
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
118
204
  // ─── Retry helpers ────────────────────────────────────────────────────────────
119
205
  const MAX_RETRIES = 5;
120
206
  const RETRY_BASE_MS = 1000;
@@ -301,6 +387,71 @@ async function streamChat(messages, options, onEvent) {
301
387
  onEvent({ type: 'retry_resolved' });
302
388
  }
303
389
  };
390
+ /**
391
+ * Abandon the current turnId and prepare to restart the turn from scratch.
392
+ *
393
+ * Used whenever the server tells us this turn can no longer be followed — the
394
+ * buffered frame window moved past our cursor (410), the turn is simply gone
395
+ * (404, e.g. the owning pod was replaced by a deploy), or a mid-stream frame
396
+ * carried `notResumable`. In all of those cases resuming again with the same
397
+ * cursor would hit the identical wall, so the only recovery is a clean restart.
398
+ *
399
+ * Two things make this more than a state reset, and both are easy to get wrong
400
+ * — which is why all three call sites funnel through here rather than each
401
+ * open-coding it:
402
+ *
403
+ * 1. The OLD turn must be cancelled and AWAITED first. A 410/404 does not mean
404
+ * the turn ended — it is very likely still generating and still holding its
405
+ * idempotency claim, so re-POSTing the same turnId would be rejected as
406
+ * `turnInFlight` and we would burn the entire retry budget waiting, while
407
+ * the orphaned turn kept generating and billing.
408
+ * 2. Anything already rendered has to be reconciled. A restart re-sends the
409
+ * answer from the beginning, so unless the caller can un-render its partial
410
+ * output, restarting would duplicate it on screen.
411
+ *
412
+ * Returns `false` when the caller cannot roll back its rendered output; the
413
+ * caller must then surface a terminal error instead of restarting.
414
+ */
415
+ const prepareRestart = async (reason) => {
416
+ await cancelTurn(turnId);
417
+ turnId = (0, crypto_1.randomUUID)(); // a restart is semantically a NEW turn
418
+ resuming = false;
419
+ serverResumable = false;
420
+ lastEventId = 0;
421
+ carryText = [];
422
+ carryToolUse = [];
423
+ // Snapshot BEFORE resetting: the rollback decision needs to know what the user
424
+ // can currently see, but the counters must not carry into the restarted turn or
425
+ // a second restart would report characters this one already discarded.
426
+ const hadRendered = emittedAnythingAcrossAttempts;
427
+ const renderedChars = emittedCharsAcrossAttempts;
428
+ emittedAnythingAcrossAttempts = false;
429
+ emittedCharsAcrossAttempts = 0;
430
+ if (hadRendered && !allowRestartAfterRender)
431
+ return false;
432
+ if (hadRendered) {
433
+ onEvent({ type: 'stream_restart', reason, discardedChars: renderedChars });
434
+ }
435
+ return true;
436
+ };
437
+ /**
438
+ * Does an error response body carry the backend's explicit `notResumable` flag?
439
+ *
440
+ * The resume endpoint sets this on every "give up on this turnId" response
441
+ * (routes/code.js: the 410 gap case, the 404 no-such-turn case, and the
442
+ * synthesised mid-stream error frames). Reading the FLAG rather than matching
443
+ * the status code alone means a new give-up condition on the server is handled
444
+ * correctly here the day it ships, instead of falling through to the generic
445
+ * terminal-error path and killing a turn that was perfectly recoverable.
446
+ */
447
+ const bodySaysNotResumable = (body) => {
448
+ try {
449
+ return JSON.parse(body)?.notResumable === true;
450
+ }
451
+ catch {
452
+ return false;
453
+ }
454
+ };
304
455
  // One network attempt: connect (with connect-time 429/5xx retry) and consume the stream to
305
456
  // completion. Returns the assembled assistant Message, or throws — tagging a transient
306
457
  // pre-content failure with { retryable: true } for the outer loop below.
@@ -318,8 +469,19 @@ async function streamChat(messages, options, onEvent) {
318
469
  // short in-loop retry budget — the final throw tags `retryable: true` so it still
319
470
  // reaches the outer 5-minute stream-retry loop instead of dying as a terminal error.
320
471
  let forbiddenExhausted = false;
472
+ // Whether this attempt has already spent its one silent token refresh. Scoped to
473
+ // the attempt, not the whole call: if a freshly-minted token is ALSO rejected the
474
+ // failure isn't expiry, and retrying would pointlessly rotate the refresh token
475
+ // against a server that keeps refusing.
476
+ let refreshedThisAttempt = false;
477
+ // Set when the failure is a dead credential that refreshing could not fix, so the
478
+ // terminal throw below can tag it `authExpired` — letting the UI show a "sign in
479
+ // again" prompt instead of a generic error, and keeping it out of the outer
480
+ // retry loop (a re-login is not something reconnecting can solve).
481
+ let sessionExpired = false;
321
482
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
322
483
  try {
484
+ errorBodyOverride = null; // a fresh response — never reuse the previous body
323
485
  response = await (0, node_fetch_1.default)(...buildFetchArgs());
324
486
  if (response.status === 429 && attempt < MAX_RETRIES && canRetry()) {
325
487
  reportRetry('Rate limited by the API — retrying');
@@ -332,6 +494,57 @@ async function streamChat(messages, options, onEvent) {
332
494
  await sleepWithinBudget(backoffMs(attempt));
333
495
  continue;
334
496
  }
497
+ // ── Expired access token → refresh once, then retry ──────────────────
498
+ //
499
+ // The backend issues access tokens with a 2-HOUR TTL and has always
500
+ // returned a long-lived rotating refresh token alongside them. The web app
501
+ // refreshes silently; the CLI and extension discarded the refresh token
502
+ // entirely and stored only the access token — so after exactly two hours,
503
+ // mid-session, every request started failing with "Invalid or expired
504
+ // token" and the run simply died with no recovery and no useful guidance.
505
+ // That is the "it just stops working after a while" report.
506
+ //
507
+ // Refreshed at most once per attempt (`refreshedThisAttempt`): if the new
508
+ // token is ALSO rejected, the problem isn't expiry and looping would just
509
+ // rotate the refresh token repeatedly against a server that keeps saying no
510
+ // — every rotation invalidating the previous one.
511
+ //
512
+ // A successful refresh retries transparently. This is before any streaming,
513
+ // so nothing has been rendered and nothing can be duplicated.
514
+ if (response.status === 401 || response.status === 403) {
515
+ const bodyText = await response.text().catch(() => '');
516
+ if (isExpiredTokenResponse(response.status, bodyText)) {
517
+ if (!refreshedThisAttempt) {
518
+ refreshedThisAttempt = true;
519
+ if (await refreshAccessToken()) {
520
+ reportRetry('Session expired — signing you back in');
521
+ attempt--; // a refresh is not a failed attempt; don't spend the budget on it
522
+ continue; // buildFetchArgs()/authHeaders() will pick up the new token
523
+ }
524
+ }
525
+ // Either refreshing was impossible (no refresh token stored — a config
526
+ // predating this feature, or a NEXRALL_TOKEN credential), it was refused
527
+ // (expired/revoked/reused), or the freshly-minted token was rejected too.
528
+ // All three genuinely need a human.
529
+ //
530
+ // Signalled by BREAKING with a rewritten body rather than throwing: a
531
+ // throw here lands in this loop's own catch, which cannot tell it from a
532
+ // network failure and would retry it — firing a burst of full-context
533
+ // POSTs at a credential that will never be accepted. Breaking routes it
534
+ // through the terminal `!response.ok` handler below, which is exactly
535
+ // what a deterministic auth failure should use.
536
+ sessionExpired = true;
537
+ errorBodyOverride = JSON.stringify({
538
+ // Replaces the backend's bare "Invalid or expired token", which reads
539
+ // like a bug rather than something the user can act on.
540
+ error: 'Your session has expired. Run `nex login` to sign in again.',
541
+ });
542
+ break;
543
+ }
544
+ // Not an expiry — hand the body we already consumed to the handlers below
545
+ // (node-fetch bodies are single-use).
546
+ errorBodyOverride = bodyText;
547
+ }
335
548
  // 403 `{"error":{"type":"forbidden","message":"Request not allowed"}}` is
336
549
  // Anthropic's own upstream response shape (reproduced verbatim through the AI
337
550
  // Gateway passthrough — see services/aiGatewayClient.js), not something Nexrall's
@@ -345,7 +558,11 @@ async function streamChat(messages, options, onEvent) {
345
558
  // burning the whole reconnect budget on something that will never succeed.
346
559
  const FORBIDDEN_RETRY_LIMIT = 2;
347
560
  if (response.status === 403) {
348
- const bodyText = await response.text().catch(() => '');
561
+ // The expiry check above already consumed the body on this path, and a
562
+ // node-fetch body is single-use — calling .text() again yields '' and the
563
+ // upstream-forbidden shape would never be recognised, turning a retryable
564
+ // blip into an instant terminal failure.
565
+ const bodyText = errorBodyOverride ?? await response.text().catch(() => '');
349
566
  let isUpstreamForbidden = false;
350
567
  try {
351
568
  const parsed = JSON.parse(bodyText);
@@ -374,53 +591,49 @@ async function streamChat(messages, options, onEvent) {
374
591
  errorBodyOverride = bodyText;
375
592
  break;
376
593
  }
377
- // 410 Gone a resume was refused because the buffered window no longer contains
378
- // our position (we were away too long, or the turn produced a huge amount in the
379
- // meantime). Continuity can't be proven, and silently skipping frames would corrupt
380
- // the message being rebuilt. So abandon resuming and fall back to restarting the
381
- // turn from scratch: correct, just not free.
382
- if (response.status === 410 && resuming) {
383
- await response.text().catch(() => '');
384
- // A 410 means the BUFFER WINDOW moved past our cursor — it does NOT mean the turn
385
- // ended. It is almost certainly still generating and still holding its idempotency
386
- // claim, so re-POSTing with the same turnId would be rejected as `turnInFlight`,
387
- // and we would wait out the entire retry budget only to fail while the orphaned
388
- // turn kept generating and billing.
389
- //
390
- // So: stop the old turn (awaited the claim must be released BEFORE we re-POST),
391
- // and mint a fresh id, because a restart is semantically a new turn.
392
- await cancelTurn(turnId);
393
- turnId = (0, crypto_1.randomUUID)();
394
- resuming = false;
395
- serverResumable = false;
396
- lastEventId = 0;
397
- carryText = [];
398
- carryToolUse = [];
399
- // Snapshot BEFORE resetting: the rollback decision below needs to know what the
400
- // user can currently see, but the counters must not carry into the restarted turn
401
- // or a second restart would report characters this one already discarded.
402
- const hadRendered = emittedAnythingAcrossAttempts;
403
- const renderedChars = emittedCharsAcrossAttempts;
404
- emittedAnythingAcrossAttempts = false;
405
- emittedCharsAcrossAttempts = 0;
406
- if (hadRendered && !allowRestartAfterRender) {
407
- // Output is already on screen and the caller can't take it back, so a restart
408
- // would duplicate it. Surface the failure instead of corrupting the transcript.
409
- throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
410
- }
411
- if (hadRendered) {
412
- onEvent({
413
- type: 'stream_restart',
414
- reason: 'Connection lost too long to resume',
415
- discardedChars: renderedChars,
416
- });
417
- }
418
- if (attempt < MAX_RETRIES && canRetry()) {
419
- reportRetry('Could not resume — restarting this turn');
420
- await sleepWithinBudget(backoffMs(attempt));
421
- continue;
594
+ // A resume that the server refuses: this turnId can no longer be followed.
595
+ //
596
+ // 410 Gone — the buffered frame window moved past our cursor (we were
597
+ // away too long, or the turn produced a lot meanwhile).
598
+ // Continuity can't be proven and skipping frames would
599
+ // corrupt the message being rebuilt.
600
+ // 404 Not Found — the turn is gone entirely: nothing buffered AND no live
601
+ // registry record, which is what a deploy/pod replacement
602
+ // looks like from here.
603
+ //
604
+ // 404 used to fall straight through to the generic terminal-error handler
605
+ // below, so a backend rolling restart mid-turn killed the turn outright with
606
+ // "No such turn to resume." — even though the recovery path for it (mint a
607
+ // new turnId and restart) was already sitting right here for 410. That was
608
+ // the "disconnects and never reconnects" case.
609
+ //
610
+ // Matched on the backend's explicit `notResumable` flag in the body rather
611
+ // than on a hardcoded status list, so a new give-up condition on the server
612
+ // is handled the day it ships. The status check keeps 410 working even if a
613
+ // proxy strips the body.
614
+ if (resuming && response.status >= 400 && response.status < 500) {
615
+ // May already have been read by the token-expiry check above (401/403);
616
+ // node-fetch bodies are single-use, so reuse it rather than reading ''.
617
+ const body = errorBodyOverride ?? await response.text().catch(() => '');
618
+ if (response.status === 410 || response.status === 404 || bodySaysNotResumable(body)) {
619
+ const canRestart = await prepareRestart('Connection lost too long to resume');
620
+ if (!canRestart) {
621
+ // Output is already on screen and the caller can't take it back, so a restart
622
+ // would duplicate it. Surface the failure instead of corrupting the transcript.
623
+ throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
624
+ }
625
+ if (attempt < MAX_RETRIES && canRetry()) {
626
+ reportRetry('Could not resume — restarting this turn');
627
+ await sleepWithinBudget(backoffMs(attempt));
628
+ continue;
629
+ }
630
+ errorBodyOverride = JSON.stringify({ error: 'Could not resume this turn — send your message again.' });
631
+ break;
422
632
  }
423
- errorBodyOverride = JSON.stringify({ error: 'Could not resume this turn send your message again.' });
633
+ // A 4xx during resume that is NOT a give-up signal (401/403 auth, 400 bad
634
+ // turnId). Deterministic — fall through to the terminal handler, reusing the
635
+ // body we already consumed since node-fetch bodies are single-use.
636
+ errorBodyOverride = body;
424
637
  break;
425
638
  }
426
639
  // 409 from the backend's turn-idempotency gate. Two distinct meanings, and the
@@ -518,7 +731,16 @@ async function streamChat(messages, options, onEvent) {
518
731
  // `retryable: true` on `forbiddenExhausted` hands the transient 403-upstream-forbidden
519
732
  // case (see above) to the outer stream-retry loop instead of letting it die as a
520
733
  // terminal error the moment the short in-connect-loop budget runs out.
521
- throw Object.assign(new Error(errMsg), { status: response.status, balance, ...(forbiddenExhausted ? { retryable: true } : {}) });
734
+ //
735
+ // `authExpired` is the opposite signal: the credential is dead and no amount of
736
+ // reconnecting will help, so it must stay terminal AND be distinguishable, so the
737
+ // UI can offer a sign-in instead of showing a generic failure.
738
+ throw Object.assign(new Error(errMsg), {
739
+ status: response.status,
740
+ balance,
741
+ ...(forbiddenExhausted ? { retryable: true } : {}),
742
+ ...(sessionExpired ? { authExpired: true } : {}),
743
+ });
522
744
  }
523
745
  if (!response.body) {
524
746
  throw new Error('Response body is null');
@@ -928,6 +1150,15 @@ async function streamChat(messages, options, onEvent) {
928
1150
  reject(tagTransient(new Error(message)));
929
1151
  }
930
1152
  else {
1153
+ // Terminal error. The three branches above each stop the watchdog
1154
+ // and tear the socket down; this one used to do neither, so a
1155
+ // fatal SSE error left a 5-second interval running and the
1156
+ // response body open for the lifetime of the process. The
1157
+ // rejection settles the promise either way, which is exactly why
1158
+ // the leak was invisible — nothing downstream ever noticed, it
1159
+ // just accumulated one timer per failed turn across a long session.
1160
+ clearInterval(heartbeatWatchdog);
1161
+ stream.destroy?.();
931
1162
  onEvent({ type: 'error', message });
932
1163
  reject(new Error(message));
933
1164
  }
@@ -1011,32 +1242,18 @@ async function streamChat(messages, options, onEvent) {
1011
1242
  if (e.retryable && sAttempt < MAX_RETRIES && canRetry()) {
1012
1243
  // The backend explicitly said this turnId's buffer is unrecoverable (gap in the
1013
1244
  // Redis frame stream, or the owning pod is gone — see the `notResumable` case in
1014
- // client.ts's SSE parser). Resuming with the same `lastEventId` would just hit
1015
- // the same wall again, so exactly like the inline 410-while-resuming handling
1016
- // above release the old claim, mint a fresh turnId, and restart clean instead
1017
- // of falling into the "prefer resuming" branch below.
1245
+ // the SSE parser above). Resuming with the same `lastEventId` would just hit the
1246
+ // same wall again, so release the old claim, mint a fresh turnId and restart
1247
+ // clean instead of falling into the "prefer resuming" branch below.
1248
+ //
1249
+ // Shares prepareRestart() with the HTTP-status path in runAttempt: the two used
1250
+ // to be independent copies of the same fifteen lines, and the cancel-then-remint
1251
+ // ordering they encode is exactly the kind of thing that silently drifts apart.
1018
1252
  if (e.forceRestart) {
1019
- await cancelTurn(turnId);
1020
- turnId = (0, crypto_1.randomUUID)();
1021
- resuming = false;
1022
- serverResumable = false;
1023
- lastEventId = 0;
1024
- carryText = [];
1025
- carryToolUse = [];
1026
- const hadRendered = emittedAnythingAcrossAttempts;
1027
- const renderedChars = emittedCharsAcrossAttempts;
1028
- emittedAnythingAcrossAttempts = false;
1029
- emittedCharsAcrossAttempts = 0;
1030
- if (hadRendered && !allowRestartAfterRender) {
1253
+ const canRestart = await prepareRestart(err.message || 'Could not resume — restarting this turn');
1254
+ if (!canRestart) {
1031
1255
  throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
1032
1256
  }
1033
- if (hadRendered) {
1034
- onEvent({
1035
- type: 'stream_restart',
1036
- reason: err.message || 'Could not resume — restarting this turn',
1037
- discardedChars: renderedChars,
1038
- });
1039
- }
1040
1257
  reportRetry('Could not resume — restarting this turn');
1041
1258
  await sleepWithinBudget(backoffMs(sAttempt));
1042
1259
  continue;
@@ -1116,12 +1333,50 @@ async function cancelTurn(turnId) {
1116
1333
  // Ignored deliberately — see above.
1117
1334
  }
1118
1335
  }
1336
+ // ─── Logout ───────────────────────────────────────────────────────────────────
1337
+ /**
1338
+ * Revoke the stored refresh token server-side.
1339
+ *
1340
+ * Deleting the local config alone is NOT a logout once refresh tokens exist: the
1341
+ * token stays valid for its full 60-day life and can keep minting access tokens
1342
+ * for anyone who recovered it (a synced dotfile, a backup, a shared machine).
1343
+ * The backend already exposes /api/auth/logout for exactly this.
1344
+ *
1345
+ * Best-effort and never throws — a failed revoke must not stop the local
1346
+ * credentials from being cleared, which is the part the user can see.
1347
+ */
1348
+ async function revokeRefreshToken() {
1349
+ const refreshToken = (0, index_1.getRefreshToken)();
1350
+ if (!refreshToken)
1351
+ return;
1352
+ try {
1353
+ await (0, node_fetch_1.default)(`${exports.API_BASE}/api/auth/logout`, {
1354
+ method: 'POST',
1355
+ headers: { 'Content-Type': 'application/json' },
1356
+ body: JSON.stringify({ refreshToken }),
1357
+ });
1358
+ }
1359
+ catch {
1360
+ // Offline logout: the local credentials still get cleared by the caller.
1361
+ }
1362
+ }
1119
1363
  // ─── Get Balance ──────────────────────────────────────────────────────────────
1120
1364
  async function getBalance() {
1121
- const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, {
1122
- method: 'GET',
1123
- headers: authHeaders(),
1124
- });
1365
+ const fetchOnce = () => (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, { method: 'GET', headers: authHeaders() });
1366
+ let response = await fetchOnce();
1367
+ // Same silent refresh as the chat path. This endpoint is polled on session start
1368
+ // and after turns, so on an expired token it is frequently the FIRST thing the
1369
+ // user sees fail — and a startup that reports "API error 403" while the chat
1370
+ // path quietly recovers is just confusing.
1371
+ if (response.status === 401 || response.status === 403) {
1372
+ const body = await response.text().catch(() => '');
1373
+ if (isExpiredTokenResponse(response.status, body) && await refreshAccessToken()) {
1374
+ response = await fetchOnce();
1375
+ }
1376
+ else {
1377
+ throw new Error(`API error ${response.status}: ${body}`);
1378
+ }
1379
+ }
1125
1380
  if (!response.ok) {
1126
1381
  const errText = await response.text();
1127
1382
  throw new Error(`API error ${response.status}: ${errText}`);
@@ -1143,7 +1398,18 @@ async function exchangeVscodeCode(code) {
1143
1398
  const data = (await response.json());
1144
1399
  if (!data.token)
1145
1400
  throw new Error('No token in exchange response');
1146
- return { token: data.token, email: data.user?.email ?? '' };
1401
+ // `refreshToken` is forwarded when present but is NOT expected today: unlike the
1402
+ // email-login path, /vscode-exchange mints a long-lived (30-day) token directly
1403
+ // rather than going through mintSession's short-access-token + refresh pair. So
1404
+ // the extension is not exposed to the 2-hour expiry that broke CLI sessions —
1405
+ // it just expires much later, with no silent renewal. Reading the field now means
1406
+ // that if the backend is ever switched to mintSession for consistency, the
1407
+ // extension starts refreshing silently instead of breaking at the 2-hour mark.
1408
+ return {
1409
+ token: data.token,
1410
+ ...(data.refreshToken ? { refreshToken: data.refreshToken } : {}),
1411
+ email: data.user?.email ?? '',
1412
+ };
1147
1413
  }
1148
1414
  // ─── Login ────────────────────────────────────────────────────────────────────
1149
1415
  async function login(email, password) {
@@ -1160,6 +1426,14 @@ async function login(email, password) {
1160
1426
  if (!data.token) {
1161
1427
  throw new Error('Login response missing token');
1162
1428
  }
1163
- return { token: data.token, email: data.email ?? email };
1429
+ // KEEP THE REFRESH TOKEN. The backend has returned one on this path all along
1430
+ // (routes/auth.js mintSession) and pairs it with an access token that expires in
1431
+ // TWO HOURS. Discarding it here is what made a long working session die mid-run
1432
+ // with "Invalid or expired token" and no way back except a manual `nex login`.
1433
+ return {
1434
+ token: data.token,
1435
+ ...(data.refreshToken ? { refreshToken: data.refreshToken } : {}),
1436
+ email: data.email ?? email,
1437
+ };
1164
1438
  }
1165
1439
  //# sourceMappingURL=client.js.map
@@ -4,4 +4,25 @@ export declare function loadAuth(): AuthConfig | null;
4
4
  export declare function clearAuth(): void;
5
5
  export declare function isAuthenticated(): boolean;
6
6
  export declare function getToken(): string | null;
7
+ /**
8
+ * The stored refresh token, or null when there isn't one to use.
9
+ *
10
+ * Deliberately returns null for a token supplied via NEXRALL_TOKEN: that path is
11
+ * for CI/headless, where the caller owns the credential's lifecycle and there is
12
+ * no refresh token to pair with it. Silently reading a refresh token out of the
13
+ * config file while the access token came from the environment would also mix two
14
+ * different identities if they disagree.
15
+ */
16
+ export declare function getRefreshToken(): string | null;
17
+ /**
18
+ * Replace the stored token pair after a successful refresh, preserving any other
19
+ * fields already in the config (notably `email`, which the refresh response does
20
+ * not echo back and which the UI shows).
21
+ *
22
+ * Refresh tokens ROTATE: the backend invalidates the presented one and returns a
23
+ * replacement, and reusing a spent token is treated as theft (`reuse_detected`)
24
+ * and revokes the family. So this must persist BOTH halves, and must not be
25
+ * skipped just because the access token is what the caller needed.
26
+ */
27
+ export declare function updateTokens(token: string, refreshToken?: string): void;
7
28
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAiB3C,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGjD;AAED,wBAAgB,QAAQ,IAAI,UAAU,GAAG,IAAI,CAwB5C;AAED,wBAAgB,SAAS,IAAI,IAAI,CAQhC;AAED,wBAAgB,eAAe,IAAI,OAAO,CAGzC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,IAAI,CAGxC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAiB3C,wBAAgB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGjD;AAED,wBAAgB,QAAQ,IAAI,UAAU,GAAG,IAAI,CAwB5C;AAED,wBAAgB,SAAS,IAAI,IAAI,CAQhC;AAED,wBAAgB,eAAe,IAAI,OAAO,CAGzC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,IAAI,CAGxC;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,IAAI,MAAM,GAAG,IAAI,CAM/C;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAsBvE"}
@@ -38,6 +38,8 @@ exports.loadAuth = loadAuth;
38
38
  exports.clearAuth = clearAuth;
39
39
  exports.isAuthenticated = isAuthenticated;
40
40
  exports.getToken = getToken;
41
+ exports.getRefreshToken = getRefreshToken;
42
+ exports.updateTokens = updateTokens;
41
43
  const fs = __importStar(require("fs"));
42
44
  const path = __importStar(require("path"));
43
45
  const os = __importStar(require("os"));
@@ -97,4 +99,55 @@ function getToken() {
97
99
  const auth = loadAuth();
98
100
  return auth?.token ?? null;
99
101
  }
102
+ /**
103
+ * The stored refresh token, or null when there isn't one to use.
104
+ *
105
+ * Deliberately returns null for a token supplied via NEXRALL_TOKEN: that path is
106
+ * for CI/headless, where the caller owns the credential's lifecycle and there is
107
+ * no refresh token to pair with it. Silently reading a refresh token out of the
108
+ * config file while the access token came from the environment would also mix two
109
+ * different identities if they disagree.
110
+ */
111
+ function getRefreshToken() {
112
+ const envToken = process.env.NEXRALL_TOKEN;
113
+ if (envToken && envToken.trim().length > 0)
114
+ return null;
115
+ const auth = loadAuth();
116
+ const rt = auth?.refreshToken;
117
+ return typeof rt === 'string' && rt.length > 0 ? rt : null;
118
+ }
119
+ /**
120
+ * Replace the stored token pair after a successful refresh, preserving any other
121
+ * fields already in the config (notably `email`, which the refresh response does
122
+ * not echo back and which the UI shows).
123
+ *
124
+ * Refresh tokens ROTATE: the backend invalidates the presented one and returns a
125
+ * replacement, and reusing a spent token is treated as theft (`reuse_detected`)
126
+ * and revokes the family. So this must persist BOTH halves, and must not be
127
+ * skipped just because the access token is what the caller needed.
128
+ */
129
+ function updateTokens(token, refreshToken) {
130
+ // Read the file directly rather than via loadAuth(): under NEXRALL_TOKEN,
131
+ // loadAuth short-circuits to the env value and would let a refresh overwrite the
132
+ // on-disk config with a synthetic single-field object, destroying the user's
133
+ // real stored credentials.
134
+ let existing = {};
135
+ try {
136
+ if (fs.existsSync(CONFIG_FILE)) {
137
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
138
+ if (typeof parsed === 'object' && parsed !== null)
139
+ existing = parsed;
140
+ }
141
+ }
142
+ catch { /* corrupt or unreadable — start clean rather than refuse to save */ }
143
+ ensureConfigDir();
144
+ const next = {
145
+ ...existing,
146
+ token,
147
+ // Keep the previous refresh token if the server didn't send a new one, rather
148
+ // than deleting the only means of refreshing again.
149
+ ...(refreshToken ? { refreshToken } : {}),
150
+ };
151
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
152
+ }
100
153
  //# sourceMappingURL=index.js.map
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from './tools/executor';
5
5
  export * from './agent/loop';
6
6
  export * from './agent/testIntegrity';
7
7
  export * from './agent/editCompleteness';
8
+ export * from './agent/securityLint';
8
9
  export * from './agent/crossFile';
9
10
  export * from './agent/flaky';
10
11
  export * from './agent/claimEvidence';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC"}