@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.
- package/dist/agent/agentTypes.d.ts +35 -2
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +241 -6
- package/dist/agent/loop.d.ts +56 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +286 -24
- package/dist/agent/securityLint.d.ts +27 -0
- package/dist/agent/securityLint.d.ts.map +1 -0
- package/dist/agent/securityLint.js +195 -0
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +351 -77
- package/dist/auth/index.d.ts +21 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +53 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +124 -18
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
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
|
|
1020
|
-
|
|
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
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -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
|
package/dist/auth/index.d.ts.map
CHANGED
|
@@ -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"}
|
package/dist/auth/index.js
CHANGED
|
@@ -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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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"}
|