Skip to content

Commit 30fca4d

Browse files
authored
Merge pull request #92 from levelcodeai/fix/session-expiry-ux
fix(cloud): an expired session is a sign-in card, found before the first message — not a 401 in red
2 parents 4e49295 + f4f38c1 commit 30fca4d

7 files changed

Lines changed: 1874 additions & 37 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,12 @@ async function runAgent(ctx) {
10511051
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' });
10521052
reason = 'error';
10531053
}
1054+
else if (typeof ctx.isSessionExpired === 'function' && ctx.isSessionExpired(e)) {
1055+
// The gateway 401 that refreshAuth could not recover: the session is over. A sign-in card,
1056+
// not the adapter's raw message — the user needs a button, not a status code.
1057+
ctx.post({ type: 'agentError', message: ctx.sessionExpiredMessage || msg, code: 'session_expired' });
1058+
reason = 'error';
1059+
}
10541060
else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; }
10551061
} finally {
10561062
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: 382 additions & 34 deletions
Large diffs are not rendered by default.

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

Lines changed: 38 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; }
@@ -2739,6 +2741,32 @@
27392741
log.appendChild(card); scrollIfStuck();
27402742
card.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => vscode.postMessage({ type: 'accountUpgrade' }); });
27412743
}
2744+
// The cloud session is over (the refresh token expired — 30 days without use, or a sign-out
2745+
// elsewhere). This is the ONE failure the user can fix in a click, so it gets a button, not red
2746+
// text: the raw "API 401: Signature has expired" this replaces told them nothing about what to do,
2747+
// while the account popover beside it still said they were signed in.
2748+
function isSessionExpired(m){ return !!(m && m.code === 'session_expired'); }
2749+
let sessionCard = null;
2750+
function addSignInCard(m){
2751+
clearStatus(); finishAgentBubble(); closeGroup();
2752+
if (sessionCard && sessionCard.isConnected){ sessionCard.remove(); } // one card, however many paths find the expiry
2753+
const who = m && m.name ? 'Welcome back, ' + esc(m.name) + '.' : '';
2754+
const card = document.createElement('div'); card.className = 'upgradecard sessioncard';
2755+
card.innerHTML =
2756+
'<div class="uchead">' + codicon('shield') + '<span>Your session has expired</span></div>'
2757+
+ '<div class="ucbody">' + (who ? who + ' ' : '') + 'Sign in again to keep using LevelCode Cloud — your chat and files here are untouched.</div>'
2758+
+ '<div class="ucbtns"><button class="ucbtn primary" data-act="signin">Sign in</button>'
2759+
+ '<button class="ucbtn ghost" data-act="byok">Use my own key instead</button></div>';
2760+
log.appendChild(card); scrollIfStuck();
2761+
card.querySelector('[data-act="signin"]').onclick = () => vscode.postMessage({ type: 'accountSignIn' });
2762+
// Choosing your own key answers the expiry — the host stops asking — so the card goes too,
2763+
// rather than sitting in the transcript offering a sign-in nobody is waiting on.
2764+
card.querySelector('[data-act="byok"]').onclick = () => {
2765+
vscode.postMessage({ type: 'byokSettings' });
2766+
card.remove(); if (sessionCard === card){ sessionCard = null; }
2767+
};
2768+
sessionCard = card;
2769+
}
27422770
// Our-side outage / transient issue → a neutral "service" notice, NOT the red error and NOT the
27432771
// upgrade card (the gateway already sanitized the message; this just picks a calmer presentation).
27442772
function isServiceIssue(m){
@@ -4127,7 +4155,8 @@
41274155
else if (m.type === 'assistantError'){
41284156
pending = ''; flushAll = false; doneSignaled = false;
41294157
const cap = capReachedInfo(m.message);
4130-
if (cap){ current = null; addUpgradeCard(cap); }
4158+
if (isSessionExpired(m)){ current = null; addSignInCard(m); }
4159+
else if (cap){ current = null; addUpgradeCard(cap); }
41314160
else if (isServiceIssue(m)){ current = null; addServiceCard(m); }
41324161
else {
41334162
const html = '<span class="err">' + esc(m.message) + '</span>';
@@ -4136,6 +4165,7 @@
41364165
}
41374166
setStreaming(false);
41384167
}
4168+
else if (m.type === 'sessionExpired'){ addSignInCard(m); }
41394169
else if (m.type === 'activeFile'){ activeFileLabel = m.label; renderChips(); }
41404170
else if (m.type === 'contextFiles'){ ctxFiles = m.files || []; renderChips(); }
41414171
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
@@ -4173,9 +4203,14 @@
41734203
else if (m.type === 'compactStart'){ ctxCompact = 'busy'; renderCtxCard(); }
41744204
else if (m.type === 'compactResult'){ onCompactResult(m); }
41754205
else if (m.type === 'debug'){ addDebug(m); }
4176-
else if (m.type === 'account'){ renderAccount(m); if (m.open) openAccount(); }
4206+
else if (m.type === 'account'){
4207+
// The sign-in card stays only while the host says an expiry is still waiting (m.expired).
4208+
// Signing in answers it; so does anything that takes the session out of play — BYOK mode
4209+
// chosen in Settings, or no cloud host to sign in to. Requests run on the user's own key by
4210+
// then, so a card still asking them to sign in would be wrong.
4211+
if (!m.expired && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); }
41774212
else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); }
4178-
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>'); } }
4213+
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>'); } }
41794214
else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); }
41804215
else if (m.type === 'context'){ selLabel = m.label; renderChips(); }
41814216
else if (m.type === 'clearContext'){ selLabel = null; renderChips(); }
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI · LevelCode Cloud session state (pure)
3+
*
4+
* The editor keeps two credentials for the cloud: a short-lived access token (8 h) and a refresh
5+
* token (30 days, rotated on use). Everything here is the arithmetic and classification around
6+
* them — no VS Code, no IO — so it can be unit-tested (test/session.test.js) and so the host can
7+
* answer "is this session still alive?" WITHOUT a network round-trip, by reading the access
8+
* token's own `exp` claim. A JWT's payload is plain base64url JSON; reading it is not verifying it
9+
* (the server does that), it is only asking the token when it says it dies.
10+
*--------------------------------------------------------------------------------------------*/
11+
// @ts-check
12+
'use strict';
13+
14+
/** Refresh this far ahead of expiry, so a request issued right now cannot land after the deadline. */
15+
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
16+
17+
/**
18+
* How long the whole refresh exchange may take — connecting, the headers AND the body. The webview's
19+
* `ready` waits on a refresh before it restores the chat, so this is how long a host that accepts the
20+
* connection and then says nothing can hold that up. Running out of it is "this attempt failed",
21+
* never "the session is over".
22+
*/
23+
const REFRESH_TIMEOUT_MS = 10 * 1000;
24+
25+
/** The sentence shown when the session is gone. Mirrors the server's own wording. */
26+
const SESSION_EXPIRED_MESSAGE = 'Your LevelCode Cloud session has expired. Sign in again to continue.';
27+
28+
/**
29+
* The `exp` claim of a JWT as epoch milliseconds, or null when the token is not a JWT, carries no
30+
* `exp`, or is unreadable. Never throws: a malformed token is a reason to re-check with the server,
31+
* not a reason to crash the editor.
32+
* @param {string|null|undefined} token
33+
* @returns {number|null}
34+
*/
35+
function jwtExpiresAt(token) {
36+
try {
37+
const parts = String(token || '').split('.');
38+
if (parts.length !== 3) { return null; }
39+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
40+
const payload = JSON.parse(Buffer.from(b64 + '='.repeat((4 - b64.length % 4) % 4), 'base64').toString('utf8'));
41+
const exp = Number(payload && payload.exp);
42+
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : null;
43+
} catch { return null; }
44+
}
45+
46+
/**
47+
* Whether an access token should be refreshed before use: it expires within the margin, has
48+
* already expired, or cannot be read at all (an unreadable token is treated as expired — the
49+
* server would reject it anyway, and asking first is cheaper than a failed request).
50+
* @param {string|null|undefined} token
51+
* @param {number} [nowMs]
52+
*/
53+
function accessNeedsRefresh(token, nowMs = Date.now()) {
54+
const exp = jwtExpiresAt(token);
55+
return exp === null || exp - nowMs <= EXPIRY_MARGIN_MS;
56+
}
57+
58+
/**
59+
* Classify the outcome of POST /auth/refresh so the caller knows whether the SESSION is over, or
60+
* only this attempt failed.
61+
*
62+
* 'ok' — a new access token was issued
63+
* 'expired' — the server rejected the refresh token itself (401): the session is over, sign in again
64+
* 'retry' — anything else: offline, 5xx, a malformed reply. Keep the tokens; nothing is known yet.
65+
*
66+
* Only an explicit 401 ends the session. Clearing credentials on a network blip would log a user
67+
* 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.
75+
* @param {{status?:number, body?:any}|null|undefined} res
76+
* @returns {'ok'|'expired'|'retry'}
77+
*/
78+
function classifyRefresh(res) {
79+
if (!res) { return 'retry'; }
80+
if (res.status === 401) { return 'expired'; }
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';
86+
}
87+
88+
/**
89+
* True when a provider error means the cloud session is dead — a gateway 401 that a refresh could
90+
* not recover. The adapter formats failures as `<label> API <status>: <detail>`; the server's own
91+
* codes (`token_expired`, `refresh_expired`) are matched too so a structured body is not needed.
92+
* @param {any} e
93+
*/
94+
function isSessionExpiredError(e) {
95+
const status = e && e.status;
96+
const msg = String((e && e.message) || e || '');
97+
return status === 401 || /\bAPI 401\b|token_expired|refresh_expired|signature has expired/i.test(msg);
98+
}
99+
100+
module.exports = { EXPIRY_MARGIN_MS, REFRESH_TIMEOUT_MS, SESSION_EXPIRED_MESSAGE, jwtExpiresAt, accessNeedsRefresh, classifyRefresh, isSessionExpiredError };
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Unit tests for providers/session.js (pure) — run: node test/session.test.js
3+
* - jwtExpiresAt: reads `exp` off a JWT payload without verifying; never throws
4+
* - accessNeedsRefresh: the 5-minute margin, expired, unreadable
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
7+
* - isSessionExpiredError: the shapes a dead session arrives in
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
const assert = require('assert');
13+
const S = require('../providers/session');
14+
15+
let n = 0;
16+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
17+
18+
/** An unsigned JWT with the given payload — the shape is all jwtExpiresAt reads. */
19+
function jwt(payload) {
20+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21+
return b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64(payload) + '.sig';
22+
}
23+
24+
const NOW = 1_800_000_000_000; // fixed "now" so the margin arithmetic is exact
25+
26+
test('jwtExpiresAt: reads exp (seconds) as epoch milliseconds', () => {
27+
assert.strictEqual(S.jwtExpiresAt(jwt({ sub: 1, exp: 1_800_000_123 })), 1_800_000_123_000);
28+
});
29+
test('jwtExpiresAt: survives base64url padding edge cases (payload lengths mod 4)', () => {
30+
for (const pad of ['', 'x', 'xy', 'xyz']) {
31+
assert.strictEqual(S.jwtExpiresAt(jwt({ p: pad, exp: 7 })), 7000, 'pad=' + JSON.stringify(pad));
32+
}
33+
});
34+
test('jwtExpiresAt: null for a non-JWT, a JWT without exp, garbage, and nothing — never throws', () => {
35+
assert.strictEqual(S.jwtExpiresAt('opaque-token'), null);
36+
assert.strictEqual(S.jwtExpiresAt(jwt({ sub: 1 })), null);
37+
assert.strictEqual(S.jwtExpiresAt('a.!!!.c'), null);
38+
assert.strictEqual(S.jwtExpiresAt(''), null);
39+
assert.strictEqual(S.jwtExpiresAt(null), null);
40+
assert.strictEqual(S.jwtExpiresAt(undefined), null);
41+
});
42+
43+
test('accessNeedsRefresh: a token with hours left does not', () => {
44+
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 3600 }), NOW), false);
45+
});
46+
test('accessNeedsRefresh: inside the 5-minute margin, at the margin, and already expired all do', () => {
47+
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 299 }), NOW), true);
48+
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 300 }), NOW), true);
49+
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 - 1 }), NOW), true);
50+
assert.strictEqual(S.accessNeedsRefresh(jwt({ exp: NOW / 1000 + 301 }), NOW), false);
51+
});
52+
test('accessNeedsRefresh: an unreadable token is treated as expired (ask the server, do not guess)', () => {
53+
assert.strictEqual(S.accessNeedsRefresh('opaque', NOW), true);
54+
assert.strictEqual(S.accessNeedsRefresh('', NOW), true);
55+
});
56+
57+
test('classifyRefresh: 200 with an access token → ok', () => {
58+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { access: 'a' } }), 'ok');
59+
assert.strictEqual(S.classifyRefresh({ status: 200, body: { token: 'a' } }), 'ok'); // legacy field name
60+
});
61+
test('classifyRefresh: ONLY an explicit 401 ends the session', () => {
62+
assert.strictEqual(S.classifyRefresh({ status: 401, body: { error: { code: 'refresh_expired' } } }), 'expired');
63+
assert.strictEqual(S.classifyRefresh({ status: 401, body: null }), 'expired');
64+
});
65+
test('classifyRefresh: offline, 5xx, 403, a 200 with no token, nothing at all → retry (tokens kept)', () => {
66+
assert.strictEqual(S.classifyRefresh(null), 'retry');
67+
assert.strictEqual(S.classifyRefresh({ status: 503, body: null }), 'retry');
68+
assert.strictEqual(S.classifyRefresh({ status: 500, body: {} }), 'retry');
69+
assert.strictEqual(S.classifyRefresh({ status: 403, body: {} }), 'retry');
70+
assert.strictEqual(S.classifyRefresh({ status: 200, body: {} }), 'retry');
71+
assert.strictEqual(S.classifyRefresh({ status: 200, body: null }), 'retry');
72+
});
73+
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+
95+
test('isSessionExpiredError: the adapter\'s "<label> API 401: …" shape, with and without e.status', () => {
96+
const e = new Error('LevelCode Cloud API 401: Your LevelCode Cloud session has expired. Sign in again to continue.');
97+
assert.strictEqual(S.isSessionExpiredError(e), true);
98+
// @ts-ignore
99+
e.status = 401; assert.strictEqual(S.isSessionExpiredError(e), true);
100+
assert.strictEqual(S.isSessionExpiredError({ status: 401 }), true);
101+
});
102+
test('isSessionExpiredError: the server codes and the old raw JWT text, as bare strings', () => {
103+
assert.strictEqual(S.isSessionExpiredError('token_expired'), true);
104+
assert.strictEqual(S.isSessionExpiredError('refresh_expired'), true);
105+
assert.strictEqual(S.isSessionExpiredError('Signature has expired'), true);
106+
});
107+
test('isSessionExpiredError: a 402 cap hit, a 500, a 4010-byte message, nothing → not a dead session', () => {
108+
assert.strictEqual(S.isSessionExpiredError(new Error('LevelCode Cloud API 402: {"error":{"code":"cap_reached"}}')), false);
109+
assert.strictEqual(S.isSessionExpiredError(new Error('LevelCode Cloud API 500: upstream')), false);
110+
assert.strictEqual(S.isSessionExpiredError(new Error('read 4010 bytes')), false);
111+
assert.strictEqual(S.isSessionExpiredError(null), false);
112+
assert.strictEqual(S.isSessionExpiredError(undefined), false);
113+
});
114+
115+
console.log('\nsession: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)