Skip to content

Commit 29ee664

Browse files
committed
fix(cloud): #92 review, round three — sign-outs, BYOK in Settings, malformed renewals
1. A sign-out could be reported as an expiry. Chat and the agent both called a gateway 401 "the session ended" whenever the window was no longer signed in. Signing out while that 401's refresh is still out leaves the window signed out as well — the superseded refresh finds no token — so someone who had just pressed Sign out was told "Your session has expired". Both paths now ask one function, isEndedSessionError, and it also requires an expiry to be waiting. One function, because the rule was written twice and had the same hole twice. 2. Choosing BYOK in Settings left the card up. The webview dropped the card only on a signed-in account message, so with the mode switched — or the cloud host cleared — requests ran on the user's own key under a card still asking them to sign in. The account message now carries `expired`, the host's own answer to "is an expiry still waiting?", and the card goes when that is false; the webview does not keep its own list of reasons. The way back is covered too: returning to gateway mode puts the expiry back in force, so the settings listener replays the card. And a session that ends while in BYOK mode no longer shows a card at all — nothing the user was doing has stopped working. 3. A malformed 2xx counted as a renewal. classifyRefresh accepted any truthy `access`, so `{ access: {} }` went to SecretStorage, which takes strings. I had moved those writes out of the refresh's try in 5182a1f, so the rejection came out of the check `ready` awaits and took the rest of initialization with it. `{ access: 'a', refresh: {} }` replaced the access token first and threw second. Both fields are validated before a reply is 'ok'. Separately, refreshCloudToken can no longer reject: a store that will not write (a locked keychain) is one more way for the attempt to fail, which is the containment that commit lost. Verified: 43 suites, 757 cases, 0 failing. sessionExpiredHost.test.js goes from 44 to 57 cases and session.test.js from 12 to 15. The agent cases run the REAL loop from agent.js — loaded whole, with the provider call replaced — through a 401 whose refresh is refused, and through a sign-out while that refresh is out. The suite takes the real fetch away, so nothing in it can reach a gateway even if a refactor routes around the stand-ins. Its SecretStorage stand-in now rejects non-strings, as the real one does. Each defect put back fails its own case: no "expiry waiting" clause -> the chat and agent sign-outs; the hook with its own copy of the rule -> the wiring guard; either token unvalidated -> session.test.js and "nothing was written"; the refresh allowed to reject -> "the keychain is locked" escapes; no `expired` on the account message, no replay on returning to gateway, the webview dismissing on sign-in alone -> each its own case. In headless Chrome against the real chat.html, the BYOK and cleared-host checks fail on the previous commit and pass on this one. tsc --checkJs reports nothing new.
1 parent 5182a1f commit 29ee664

6 files changed

Lines changed: 334 additions & 38 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,16 @@ async function refreshCloudToken() {
380380
if (!ctx) { return false; }
381381
const endpoint = cloudApiUrl();
382382
if (!/^https:\/\//i.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(endpoint)) { return false; }
383+
// Never rejects. The stores can throw as well as the network (a locked keychain, say), and the
384+
// callers are a webview's `ready`, a timer on window focus, and the 401 path of a request that is
385+
// already in trouble — none of which can do anything with that except fail worse. It is one more
386+
// way for this attempt to fail.
387+
try { return await renewSession(endpoint); }
388+
catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); return false; }
389+
}
390+
391+
/** refreshCloudToken's work, free to throw. */
392+
async function renewSession(endpoint) {
383393
// Which session this request is about. Read under the lock, so never halfway through a sign-in.
384394
const was = await withSessionLock(async () => ({ generation: sessionGeneration, refresh: await ctx.secrets.get(ACCOUNT_REFRESH_KEY) }));
385395
if (!was.refresh) { return false; }
@@ -496,7 +506,10 @@ async function sessionExpired(was) {
496506
if (!ended) { return false; }
497507
const p = ctx.globalState.get(ACCOUNT_PROFILE_KEY) || {};
498508
dbg('cloud.sessionExpired', { name: p.name || p.email || '' });
499-
postSessionExpired();
509+
// In BYOK mode nothing the user is doing has stopped working, so there is no card to show — the
510+
// popover going back to "signed out" is the whole of it. (The account message that follows would
511+
// take the card straight down again anyway.) The marker is kept for if they return to gateway mode.
512+
if (sessionExpiredPending()) { postSessionExpired(); }
500513
await postAccount(false);
501514
sendConfigToWebview();
502515
return true;
@@ -512,20 +525,43 @@ function postSessionExpired() {
512525
* True while an ended session is still waiting on the user: sessionExpired() left its marker, and
513526
* nothing has answered it — a sign-in, a sign-out, or "use my own key" (clearSessionExpired).
514527
*
515-
* Two things read it, and both need more than "there is no token":
528+
* Everything about the sign-in card hangs off it, and each use needs more than "there is no token":
516529
*
517-
* 1. The replay on a webview's `ready`. The session can end with no chat open to hear about it
530+
* 1. Showing it — replaySessionExpired. The session can end with no chat open to hear about it
518531
* (the focus check does not need one), and post() has nowhere to send the card.
519-
* 2. prepProviderRequest. Gateway is the DEFAULT mode, so "gateway mode, no token" describes
520-
* everyone who uses LevelCode on their own key with no account. Only a session that ended may
521-
* stop a request and ask for a sign-in; the rest fall back to BYOK, as the setting promises.
532+
* 2. Stopping a request to ask for a sign-in — prepProviderRequest. Gateway is the DEFAULT mode,
533+
* so "gateway mode, no token" describes everyone who uses LevelCode on their own key with no
534+
* account. Only a session that ended may stop a request; the rest fall back to BYOK, as the
535+
* setting promises.
536+
* 3. Calling a failed request an expiry — isEndedSessionError. A 401 that lands after a
537+
* deliberate sign-out finds no session either, and is not one.
538+
* 4. Taking it away — currentAccount reports it as `expired`, and the webview drops the card as
539+
* soon as that is false.
522540
*
523-
* Scoped to where the card can be acted on: gateway mode, with a host to sign in to.
541+
* Scoped to where the card can be acted on: gateway mode, with a host to sign in to. Leaving either
542+
* (BYOK chosen in Settings, a cleared host) puts the expiry out of play WITHOUT answering it: the
543+
* marker stays, and coming back brings the card back.
524544
*/
525545
function sessionExpiredPending() {
526546
return !!(ctx && ctx.globalState.get(ACCOUNT_EXPIRED_KEY)) && providerMode() === 'gateway' && !!cloudEndpoint();
527547
}
528548

549+
/**
550+
* Is this failure the session having ENDED — the one failure that gets the sign-in card instead of
551+
* its own message? Chat and the agent both ask it about a gateway 401 their refresh could not
552+
* recover, and every clause is there for a case that looks the same without it:
553+
*
554+
* - a gateway request: a BYOK provider's 401 is that provider's business;
555+
* - this window no longer signed in: a refresh that merely failed leaves the session alive;
556+
* - an expiry still waiting on the user: a 401 that lands after they signed OUT, mid-request,
557+
* finds the session gone as well — and "your session has expired" is the wrong thing to tell
558+
* someone who has just pressed Sign out;
559+
* - and the error being that 401 at all.
560+
*/
561+
function isEndedSessionError(req, e) {
562+
return !!(req && req.gateway) && !cloudSignedIn && sessionExpiredPending() && session.isSessionExpiredError(e);
563+
}
564+
529565
/** The user has answered the expiry, so stop asking: the marker goes. Unconditional — another window
530566
* may have set it, and this window's view of globalState can lag behind the write. */
531567
async function clearSessionExpired() {
@@ -1918,8 +1954,9 @@ async function agentFlow(text, imageBlocks) {
19181954
},
19191955
// After refreshAuth has failed on a gateway 401, agent.js asks whether that was the session
19201956
// ending (→ it posts a sign-in card) or just an error. The refresh itself already ran
1921-
// sessionExpired() when the server said the refresh token was dead.
1922-
isSessionExpired: (e) => !!req.gateway && !cloudSignedIn && session.isSessionExpiredError(e),
1957+
// sessionExpired() when the server said the refresh token was dead. The same question, asked
1958+
// the same way, as the chat path's — see isEndedSessionError.
1959+
isSessionExpired: (e) => isEndedSessionError(req, e),
19231960
sessionExpiredMessage: session.SESSION_EXPIRED_MESSAGE,
19241961
skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver)
19251962
projectMemory: projectMemoryMarkdown(), // cross-session memory: a verify-first digest of past sessions, injected like project rules
@@ -2242,9 +2279,9 @@ async function handleSend(text, images) {
22422279
// is what clears cloudSignedIn). A 401 whose refresh merely failed — offline, a 5xx, a
22432280
// malformed reply — is this request failing, not the session: the tokens stay, the error is
22442281
// shown as the error it is, and the next message tries the refresh again. Ending the session
2245-
// from here would delete credentials classifyRefresh deliberately kept. Same rule as the
2246-
// agent's isSessionExpired hook.
2247-
if (req.gateway && !cloudSignedIn && session.isSessionExpiredError(e)) {
2282+
// from here would delete credentials classifyRefresh deliberately kept. The agent's
2283+
// isSessionExpired hook asks the same question: isEndedSessionError.
2284+
if (isEndedSessionError(req, e)) {
22482285
post({ type: 'assistantError', message: session.SESSION_EXPIRED_MESSAGE, code: 'session_expired' });
22492286
} else {
22502287
post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code });
@@ -3029,9 +3066,12 @@ async function currentAccount() {
30293066
cloudSignedIn = !!token; // keep the footer/model gate in sync with the real session
30303067
if (token) {
30313068
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
3032-
return { signedIn: true, mode, name: p.name || p.email || 'LevelCode user', email: p.email || '', plan: p.plan || '' };
3069+
return { signedIn: true, mode, expired: false, name: p.name || p.email || 'LevelCode user', email: p.email || '', plan: p.plan || '' };
30333070
}
3034-
return { signedIn: false, mode, status: cloudEndpoint() ? 'signedout' : 'unconfigured' };
3071+
// `expired`: an ended session is still waiting on the user. The webview keeps its sign-in card only
3072+
// while this says so — which is how the card leaves when the expiry is answered somewhere other
3073+
// than the card itself: switching to BYOK in Settings, or clearing the cloud host.
3074+
return { signedIn: false, mode, expired: sessionExpiredPending(), status: cloudEndpoint() ? 'signedout' : 'unconfigured' };
30353075
}
30363076
/**
30373077
* Best-effort refresh of the cached profile (esp. `plan`) from the backend, so the free-tier lock /
@@ -3241,6 +3281,22 @@ async function openWorkspaceFile(rel) {
32413281
} catch (e) { dbg('openFile.error', { msg: String((e && e.message) || e) }); }
32423282
}
32433283

3284+
/**
3285+
* Settings changed. The routing mode and the cloud host decide whether a session is in play at all,
3286+
* so they resync the account popover — and with it the sign-in card, in BOTH directions. Leaving
3287+
* gateway mode (or clearing the host) answers an unanswered expiry as far as the screen goes: the
3288+
* account message says nothing is pending and the webview drops the card. Coming back puts the
3289+
* expiry back in force, and the card has to come back with it — otherwise the next message is
3290+
* stopped by something nothing on screen mentions.
3291+
*/
3292+
function onConfigChanged(e) {
3293+
if (e.affectsConfiguration('levelcode.ai')) { sendConfigToWebview(); }
3294+
if (e.affectsConfiguration('levelcode.ai.providerMode') || e.affectsConfiguration('levelcode.cloud')) {
3295+
postAccount();
3296+
replaySessionExpired().catch(() => { });
3297+
}
3298+
}
3299+
32443300
function activate(context) {
32453301
// A window that comes back after a while away may have outlived its access token (8 h). Re-check
32463302
// on focus, throttled, so the expiry is found before the next message rather than by it.
@@ -3316,10 +3372,7 @@ function activate(context) {
33163372
vscode.workspace.onDidDeleteFiles(scheduleFileIndex),
33173373
vscode.workspace.onDidRenameFiles(scheduleFileIndex),
33183374
vscode.workspace.onDidChangeWorkspaceFolders(scheduleFileIndex),
3319-
vscode.workspace.onDidChangeConfiguration((e) => {
3320-
if (e.affectsConfiguration('levelcode.ai')) { sendConfigToWebview(); }
3321-
if (e.affectsConfiguration('levelcode.ai.providerMode') || e.affectsConfiguration('levelcode.cloud')) { postAccount(); }
3322-
})
3375+
vscode.workspace.onDidChangeConfiguration(onConfigChanged)
33233376
);
33243377

33253378
// AI edit-with-diff (select code → instruct → review diff → apply) — provider-agnostic.

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4198,7 +4198,11 @@
41984198
else if (m.type === 'compactResult'){ onCompactResult(m); }
41994199
else if (m.type === 'debug'){ addDebug(m); }
42004200
else if (m.type === 'account'){
4201-
if (m.signedIn && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); }
4201+
// The sign-in card stays only while the host says an expiry is still waiting (m.expired).
4202+
// Signing in answers it; so does anything that takes the session out of play — BYOK mode
4203+
// chosen in Settings, or no cloud host to sign in to. Requests run on the user's own key by
4204+
// then, so a card still asking them to sign in would be wrong.
4205+
if (!m.expired && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); }
42024206
else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); }
42034207
else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (isSessionExpired(m)){ addSignInCard(m); } else if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } }
42044208
else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); }

‎extensions/levelcode-ai/providers/session.js‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,24 @@ function accessNeedsRefresh(token, nowMs = Date.now()) {
6565
*
6666
* Only an explicit 401 ends the session. Clearing credentials on a network blip would log a user
6767
* out for closing their laptop on the train.
68+
*
69+
* And 'ok' means the reply can be STORED as it stands, not merely that it was a 2xx with something
70+
* in the right field. The tokens go into SecretStorage, which takes strings: `{ access: {} }` would
71+
* throw on the way in, and `{ access: 'a', refresh: {} }` would throw after the access token had
72+
* already been replaced. Either is a malformed reply — "nothing is known yet" — and the credentials
73+
* in hand are still the best ones available. The refresh token is optional (a server that does not
74+
* rotate sends none); when one is present it has to be usable too.
6875
* @param {{status?:number, body?:any}|null|undefined} res
6976
* @returns {'ok'|'expired'|'retry'}
7077
*/
7178
function classifyRefresh(res) {
7279
if (!res) { return 'retry'; }
7380
if (res.status === 401) { return 'expired'; }
74-
if (res.status >= 200 && res.status < 300 && res.body && (res.body.access || res.body.token)) { return 'ok'; }
75-
return 'retry';
81+
if (!(res.status >= 200 && res.status < 300) || !res.body) { return 'retry'; }
82+
const isToken = (v) => typeof v === 'string' && v.length > 0;
83+
if (!isToken(res.body.access || res.body.token)) { return 'retry'; } // the one the host will store
84+
if (res.body.refresh && !isToken(res.body.refresh)) { return 'retry'; }
85+
return 'ok';
7686
}
7787

7888
/**

‎extensions/levelcode-ai/test/session.test.js‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Unit tests for providers/session.js (pure) — run: node test/session.test.js
33
* - jwtExpiresAt: reads `exp` off a JWT payload without verifying; never throws
44
* - accessNeedsRefresh: the 5-minute margin, expired, unreadable
5-
* - classifyRefresh: ONLY a 401 ends the session; offline/5xx keep the tokens
5+
* - classifyRefresh: ONLY a 401 ends the session; offline/5xx keep the tokens; a 2xx is a
6+
* renewal only when what it carries can be stored
67
* - isSessionExpiredError: the shapes a dead session arrives in
78
*--------------------------------------------------------------------------------------------*/
89
// @ts-check
@@ -70,6 +71,27 @@ test('classifyRefresh: offline, 5xx, 403, a 200 with no token, nothing at all
7071
assert.strictEqual(S.classifyRefresh({ status: 200, body: null }), 'retry');
7172
});
7273

74+
test('classifyRefresh: a 2xx whose access token is not a usable string is NOT a renewal', () => {
75+
for (const access of [{}, [], ['a'], 123, true]) {
76+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access } }), 'retry', 'access=' + JSON.stringify(access));
77+
}
78+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { token: {} } }), 'retry', 'the legacy field too');
79+
// The host stores `access || token`, so that is the one judged: a broken `access` is not rescued by a
80+
// good `token` beside it, and an EMPTY `access` falls through to `token` exactly as the store would.
81+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: {}, token: 'legacy' } }), 'retry');
82+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: '', token: 'legacy' } }), 'ok');
83+
});
84+
test('classifyRefresh: a refresh token, when one is sent, has to be a usable string too', () => {
85+
for (const refresh of [{}, [], ['r'], 123, true]) {
86+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: 'a', refresh } }), 'retry', 'refresh=' + JSON.stringify(refresh));
87+
}
88+
});
89+
test('classifyRefresh: no refresh token is still ok — a server that does not rotate sends none', () => {
90+
for (const body of [{ access: 'a' }, { access: 'a', refresh: null }, { access: 'a', refresh: '' }, { access: 'a', refresh: 'r' }, { token: 'a', refresh: 'r' }]) {
91+
assert.strictEqual(S.classifyRefresh({ status: 200, body }), 'ok', JSON.stringify(body));
92+
}
93+
});
94+
7395
test('isSessionExpiredError: the adapter\'s "<label> API 401: …" shape, with and without e.status', () => {
7496
const e = new Error('LevelCode Cloud API 401: Your LevelCode Cloud session has expired. Sign in again to continue.');
7597
assert.strictEqual(S.isSessionExpiredError(e), true);

0 commit comments

Comments
 (0)