Skip to content

Commit 4e1453f

Browse files
committed
fix(cloud): an expired session is a sign-in card, found before the first message — not a 401 in red
A user back from a week away typed a goal and got "LevelCode Cloud API 401: Signature has expired" in the transcript, while the account popover said they were signed in. Three things made that possible, and each is closed here. The session could die on a schedule nobody could see. The editor stored the access token the refresh endpoint returned and kept its ORIGINAL refresh token forever, so the 30 days ran from the last sign-in. The server now rotates the refresh token (thin.ly #438) and the editor stores it, so the window slides with use. "Signed in" meant "a secret exists". cloudSignedIn was the presence of a token in SecretStorage, never its validity, so dead credentials kept the footer on "Gateway · Max" and the popover on "Manage account". refreshCloudToken now classifies the refresh reply (providers/session.js): only an explicit 401 ends the session — offline and 5xx keep the tokens, because clearing credentials on a network blip would log someone out for closing their laptop on the train. When it IS over, sessionExpired() forgets the tokens, flips the flag, resyncs the popover, and tells the webview. The failure was discovered by the user's first message. The access token's own `exp` is now read locally at the webview's ready and on window focus (throttled), and the network is touched only when it is expired or within five minutes of it — so an expiry found on launch is a sign-in card at the top of an empty chat, not the reply to a goal they just typed. A send that still finds the session dead — chat, agent, or a prep failure in gateway mode with no token — posts code 'session_expired', which the webview routes to the card ahead of the cap and service cards and the red-text fallback. Gateway mode with no token is named for what it is, "signed out", rather than "No API key set for OpenAI". Verified: session 12 tests, sessionExpiredUi 10; full suite 42 suites, 621 cases, 0 failing. Each guard reverted in turn fails only its own assertion: the session check moved behind the cap card -> the ordering guard; agent.js no longer naming the dead session -> the agent assertion; the ready check removed -> the startup guard. No tsconfig exists, so node --check is the syntax gate.
1 parent 119fef2 commit 4e1453f

6 files changed

Lines changed: 403 additions & 14 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,12 @@ async function runAgent(ctx) {
10481048
ctx.post({ type: 'agentError', message: 'You’ve hit the model’s context window (the conversation got too long). Start a New chat to reset it, switch to a larger-context model, or pin fewer files — then continue.', kind: 'context' });
10491049
reason = 'error';
10501050
}
1051+
else if (typeof ctx.isSessionExpired === 'function' && ctx.isSessionExpired(e)) {
1052+
// The gateway 401 that refreshAuth could not recover: the session is over. A sign-in card,
1053+
// not the adapter's raw message — the user needs a button, not a status code.
1054+
ctx.post({ type: 'agentError', message: ctx.sessionExpiredMessage || msg, code: 'session_expired' });
1055+
reason = 'error';
1056+
}
10511057
else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; }
10521058
} finally {
10531059
dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null });

‎extensions/levelcode-ai/extension.js‎

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const crypto = require('crypto');
1717
const providers = require('./providers/index');
1818
const catalog = require('./providers/catalog');
1919
const { resolveGateway } = require('./providers/gateway');
20+
const session = require('./providers/session');
2021
const { registerAiEdit } = require('./aiEdit');
2122
const { registerLmProvider } = require('./lmProvider');
2223
const { registerInlineComplete } = require('./inlineComplete');
@@ -202,6 +203,7 @@ function providerErrorMessage(req) {
202203
if (req.reason === 'baseURL') { return 'Set a base URL for the custom OpenAI-compatible provider first (levelcode.ai.baseURL).'; }
203204
if (req.reason === 'insecureBaseURL') { return 'Refusing to send your API key over plain http to a non-local host. Use an https base URL (or a localhost endpoint) for the custom provider.'; }
204205
if (req.reason === 'insecureGateway') { return 'Refusing to send your LevelCode Cloud token over plain http. Set "levelcode.cloud.endpoint" to an https URL to use gateway mode.'; }
206+
if (req.reason === 'signedOut') { return session.SESSION_EXPIRED_MESSAGE; }
205207
return 'No API key set for ' + req.label + '. Use the key button or “LevelCode: AI: Set API Key”.';
206208
}
207209

@@ -347,26 +349,89 @@ function isAuthError(e) { return /\bAPI 401\b|\b401\b.*unauthor/i.test(String((e
347349
* {apiUrl}/api/levelcode/v1/auth/refresh (the Rails backend), store the new access token, return true.
348350
* No-op (returns false) when not applicable (byok, signed out, no refresh token, or non-https apiUrl).
349351
*/
352+
/**
353+
* Renew the access token from the refresh token. Returns true on success.
354+
*
355+
* Two things this now does that it did not before, both for the same incident — a user back from a
356+
* week away was shown "LevelCode Cloud API 401: Signature has expired" in the transcript while the
357+
* account popover still said they were signed in:
358+
*
359+
* 1. It STORES the rotated refresh token the server now returns. Before, the editor kept the
360+
* refresh token from sign-in for its whole life, so the 30 days ran from the last sign-in rather
361+
* than the last use, and an active user was logged out on a schedule they could not see.
362+
* 2. It knows the difference between "this attempt failed" and "the session is over". Only an
363+
* explicit 401 from the refresh endpoint is the latter; that ends the session (sessionExpired).
364+
* Offline, a 5xx, a malformed reply: nothing is known yet, so the tokens stay.
365+
*/
350366
async function refreshCloudToken() {
351367
if (!ctx) { return false; }
352368
const endpoint = cloudApiUrl();
353369
if (!/^https:\/\//i.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(endpoint)) { return false; }
354370
const refresh = await ctx.secrets.get(ACCOUNT_REFRESH_KEY);
355371
if (!refresh) { return false; }
372+
let outcome = 'retry';
356373
try {
357374
const res = await fetch(endpoint + '/api/levelcode/v1/auth/refresh', {
358375
method: 'POST',
359376
headers: { 'content-type': 'application/json' },
360377
body: JSON.stringify({ refresh })
361378
});
362-
if (!res.ok) { dbg('cloud.refresh', { ok: false, status: res.status }); return false; }
363379
const data = await res.json().catch(() => null);
364-
const access = data && (data.access || data.token);
365-
if (!access) { return false; }
366-
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
367-
dbg('cloud.refresh', { ok: true });
368-
return true;
369-
} catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); return false; }
380+
outcome = session.classifyRefresh({ status: res.status, body: data });
381+
dbg('cloud.refresh', { outcome, status: res.status, code: data && data.error && data.error.code });
382+
if (outcome === 'ok') {
383+
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, data.access || data.token);
384+
if (data.refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, data.refresh); }
385+
return true;
386+
}
387+
} catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); }
388+
if (outcome === 'expired') { await sessionExpired(); }
389+
return false;
390+
}
391+
392+
/**
393+
* The cloud session is over and cannot be renewed: forget the dead credentials, tell the webview,
394+
* and resync the account popover so it stops claiming the user is signed in.
395+
*
396+
* The cached profile is deliberately KEPT — the sign-in card can say who it is talking to, and the
397+
* next sign-in overwrites it anyway. What must go is anything the editor would otherwise keep
398+
* presenting as a live session: the tokens, and the `cloudSignedIn` flag the footer and model gate
399+
* read. Idempotent, so every path that discovers the expiry can call it without coordination.
400+
*/
401+
let sessionExpiredAnnounced = false;
402+
async function sessionExpired() {
403+
const hadToken = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY));
404+
cloudSignedIn = false;
405+
if (ctx) {
406+
await ctx.secrets.delete(ACCOUNT_TOKEN_KEY);
407+
await ctx.secrets.delete(ACCOUNT_REFRESH_KEY);
408+
}
409+
if (!hadToken && sessionExpiredAnnounced) { return; }
410+
sessionExpiredAnnounced = true;
411+
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
412+
dbg('cloud.sessionExpired', { name: p.name || p.email || '' });
413+
post({ type: 'sessionExpired', name: p.name || '', message: session.SESSION_EXPIRED_MESSAGE });
414+
await postAccount(false);
415+
sendConfigToWebview();
416+
}
417+
418+
/**
419+
* Check the session BEFORE the user types anything, so an expiry found on launch shows up as a
420+
* sign-in card at the top of the chat rather than as the reply to their first message.
421+
*
422+
* Cheap by design: the access token's own `exp` is read locally and the network is only touched
423+
* when it is expired or about to be. A session with hours left costs nothing here. Called on the
424+
* webview's `ready` and again when the window regains focus after a while away.
425+
*/
426+
let lastSessionCheck = 0;
427+
async function checkCloudSession(reason) {
428+
if (!ctx || providerMode() !== 'gateway') { return; }
429+
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
430+
if (!token) { return; }
431+
lastSessionCheck = Date.now();
432+
if (!session.accessNeedsRefresh(token)) { return; }
433+
dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
434+
await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
370435
}
371436

372437
/** Gateway-mode token refresh (the streaming 401 retry path). Delegates to refreshCloudToken. */
@@ -387,6 +452,12 @@ async function prepProviderRequest(opts) {
387452
// LevelCode Cloud metered gateway (an openai-kind endpoint) instead of the user's own provider/key. Falls
388453
// back to the BYOK path below when signed out or in byok mode — the default is untouched.
389454
const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
455+
// Gateway mode with no token at all is a signed-out gateway user — most often one whose session
456+
// just expired — not a BYOK user who forgot a key. Say so, with a sign-in card, instead of "No API
457+
// key set for OpenAI", which sends them hunting for a key they never needed.
458+
if (providerMode() === 'gateway' && !token && cloudEndpoint()) {
459+
return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: 'signedOut', gateway: true };
460+
}
390461
const gw = resolveGateway({ mode: providerMode(), endpoint: cloudApiUrl(), token: token || '' });
391462
if (gw.use) {
392463
if (!gw.ok) { return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: gw.reason }; }
@@ -1633,7 +1704,7 @@ async function agentFlow(text, imageBlocks) {
16331704
return;
16341705
}
16351706
const req = await prepProviderRequest({ prompt: true });
1636-
if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req) }); post({ type: 'agentDone', reason: 'error' }); return; }
1707+
if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req), code: req.reason === 'signedOut' ? 'session_expired' : undefined }); post({ type: 'agentDone', reason: 'error' }); return; }
16371708

16381709
post({ type: 'agentStart' });
16391710
const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW
@@ -1702,6 +1773,11 @@ async function agentFlow(text, imageBlocks) {
17021773
if (!req.gateway) { return null; }
17031774
return (await refreshGatewayToken()) ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
17041775
},
1776+
// After refreshAuth has failed on a gateway 401, agent.js asks whether that was the session
1777+
// ending (→ it posts a sign-in card) or just an error. The refresh itself already ran
1778+
// sessionExpired() when the server said the refresh token was dead.
1779+
isSessionExpired: (e) => !!req.gateway && !cloudSignedIn && session.isSessionExpiredError(e),
1780+
sessionExpiredMessage: session.SESSION_EXPIRED_MESSAGE,
17051781
skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver)
17061782
projectMemory: projectMemoryMarkdown(), // cross-session memory: a verify-first digest of past sessions, injected like project rules
17071783
// The recall_sessions tool: search past-session outcomes on demand. Off → the tool isn't offered at all.
@@ -1984,7 +2060,7 @@ async function handleSend(text, images) {
19842060
const req = await prepProviderRequest({ prompt: true });
19852061
if (!req.ok) {
19862062
conversation.pop();
1987-
post({ type: 'assistantError', message: providerErrorMessage(req) });
2063+
post({ type: 'assistantError', message: providerErrorMessage(req), code: req.reason === 'signedOut' ? 'session_expired' : undefined });
19882064
return;
19892065
}
19902066
const doStream = (r) => providers.streamChat({
@@ -2014,7 +2090,14 @@ async function handleSend(text, images) {
20142090
post({ type: 'assistantDone' });
20152091
} else {
20162092
conversation.pop();
2017-
post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code });
2093+
// A gateway 401 the refresh above could not recover is a dead session, not an error to read:
2094+
// name it so the webview shows the sign-in card instead of the adapter's raw message.
2095+
if (req.gateway && session.isSessionExpiredError(e)) {
2096+
await sessionExpired();
2097+
post({ type: 'assistantError', message: session.SESSION_EXPIRED_MESSAGE, code: 'session_expired' });
2098+
} else {
2099+
post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code });
2100+
}
20182101
}
20192102
} finally {
20202103
// Only if this turn still owns it. A new turn may already have installed its own controller, and
@@ -2497,7 +2580,7 @@ class ChatViewProvider {
24972580
switch (msg.type) {
24982581
// `ready` is the earliest a freshly-loaded webview can hear anything, so it is also where a
24992582
// surface that just took over replays the conversation it inherited (openChatInEditor).
2500-
case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break;
2583+
case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); await checkCloudSession('ready'); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break;
25012584
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
25022585
case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break;
25032586
case 'send': await handleSend(msg.text, msg.images); break;
@@ -2528,6 +2611,8 @@ class ChatViewProvider {
25282611
case 'accountSignOut': await accountSignOut(); break;
25292612
case 'accountManage': await accountManage(); break;
25302613
case 'accountUpgrade': await openUpgrade(); break;
2614+
// The sign-in card's second button: the same setting the model picker's BYOK row opens.
2615+
case 'byokSettings': vscode.commands.executeCommand('workbench.action.openSettings', 'levelcode.ai.providerMode'); break;
25312616
case 'openExternal': await openLegal(msg.target); break;
25322617
case 'copy': try { await vscode.env.clipboard.writeText(String(msg.text || '')); } catch (e) { /* clipboard unavailable */ } break;
25332618
case 'retry': if (lastAgentGoal && !abort) { dbg('retry', { goalChars: lastAgentGoal.length }); await agentFlow(lastAgentGoal); } break;
@@ -2994,6 +3079,11 @@ async function openWorkspaceFile(rel) {
29943079
}
29953080

29963081
function activate(context) {
3082+
// A window that comes back after a while away may have outlived its access token (8 h). Re-check
3083+
// on focus, throttled, so the expiry is found before the next message rather than by it.
3084+
context.subscriptions.push(vscode.window.onDidChangeWindowState((st) => {
3085+
if (st.focused && Date.now() - lastSessionCheck > 10 * 60 * 1000) { checkCloudSession('focus').catch(() => {}); }
3086+
}));
29973087
ctx = context;
29983088
// Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a
29993089
// contributed view, but the panel still needs the one object that owns wire()/makeLive().

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,8 @@
362362
.upgradecard .ucbtn.primary:hover { filter: brightness(1.1); }
363363
.upgradecard .ucbtn.ghost { background: transparent; border: 1px solid var(--border); color: var(--vscode-foreground); }
364364
.upgradecard .ucbtn.ghost:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.14)); }
365+
/* session expired — the upgrade card's shape, because it too ends in one button the user wants to press */
366+
.sessioncard .uchead .ci { color: var(--accent); }
365367
/* our-side outage notice — neutral, NO accent, NO CTA (it is not the user's account/usage) */
366368
.noticecard { border: 1px solid var(--border); border-radius: 12px; margin: 8px 2px; padding: 13px 15px 14px; background: var(--field-bg); }
367369
.noticecard .uchead { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
@@ -2733,6 +2735,27 @@
27332735
log.appendChild(card); scrollIfStuck();
27342736
card.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => vscode.postMessage({ type: 'accountUpgrade' }); });
27352737
}
2738+
// The cloud session is over (the refresh token expired — 30 days without use, or a sign-out
2739+
// elsewhere). This is the ONE failure the user can fix in a click, so it gets a button, not red
2740+
// text: the raw "API 401: Signature has expired" this replaces told them nothing about what to do,
2741+
// while the account popover beside it still said they were signed in.
2742+
function isSessionExpired(m){ return !!(m && m.code === 'session_expired'); }
2743+
let sessionCard = null;
2744+
function addSignInCard(m){
2745+
clearStatus(); finishAgentBubble(); closeGroup();
2746+
if (sessionCard && sessionCard.isConnected){ sessionCard.remove(); } // one card, however many paths find the expiry
2747+
const who = m && m.name ? 'Welcome back, ' + esc(m.name) + '.' : '';
2748+
const card = document.createElement('div'); card.className = 'upgradecard sessioncard';
2749+
card.innerHTML =
2750+
'<div class="uchead">' + codicon('shield') + '<span>Your session has expired</span></div>'
2751+
+ '<div class="ucbody">' + (who ? who + ' ' : '') + 'Sign in again to keep using LevelCode Cloud — your chat and files here are untouched.</div>'
2752+
+ '<div class="ucbtns"><button class="ucbtn primary" data-act="signin">Sign in</button>'
2753+
+ '<button class="ucbtn ghost" data-act="byok">Use my own key instead</button></div>';
2754+
log.appendChild(card); scrollIfStuck();
2755+
card.querySelector('[data-act="signin"]').onclick = () => vscode.postMessage({ type: 'accountSignIn' });
2756+
card.querySelector('[data-act="byok"]').onclick = () => vscode.postMessage({ type: 'byokSettings' });
2757+
sessionCard = card;
2758+
}
27362759
// Our-side outage / transient issue → a neutral "service" notice, NOT the red error and NOT the
27372760
// upgrade card (the gateway already sanitized the message; this just picks a calmer presentation).
27382761
function isServiceIssue(m){
@@ -4121,7 +4144,8 @@
41214144
else if (m.type === 'assistantError'){
41224145
pending = ''; flushAll = false; doneSignaled = false;
41234146
const cap = capReachedInfo(m.message);
4124-
if (cap){ current = null; addUpgradeCard(cap); }
4147+
if (isSessionExpired(m)){ current = null; addSignInCard(m); }
4148+
else if (cap){ current = null; addUpgradeCard(cap); }
41254149
else if (isServiceIssue(m)){ current = null; addServiceCard(m); }
41264150
else {
41274151
const html = '<span class="err">' + esc(m.message) + '</span>';
@@ -4130,6 +4154,7 @@
41304154
}
41314155
setStreaming(false);
41324156
}
4157+
else if (m.type === 'sessionExpired'){ addSignInCard(m); }
41334158
else if (m.type === 'activeFile'){ activeFileLabel = m.label; renderChips(); }
41344159
else if (m.type === 'contextFiles'){ ctxFiles = m.files || []; renderChips(); }
41354160
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
@@ -4167,9 +4192,10 @@
41674192
else if (m.type === 'compactStart'){ ctxCompact = 'busy'; renderCtxCard(); }
41684193
else if (m.type === 'compactResult'){ onCompactResult(m); }
41694194
else if (m.type === 'debug'){ addDebug(m); }
4170-
else if (m.type === 'account'){ renderAccount(m); if (m.open) openAccount(); }
4195+
else if (m.type === 'account'){
4196+
if (m.signedIn && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); }
41714197
else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); }
4172-
else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '<span class="err">' + esc(m.message) + '</span>'); } }
4198+
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>'); } }
41734199
else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); }
41744200
else if (m.type === 'context'){ selLabel = m.label; renderChips(); }
41754201
else if (m.type === 'clearContext'){ selLabel = null; renderChips(); }

0 commit comments

Comments
 (0)