fix: stop cross-project live session leakage and stale-adapter 401s

Field session on a nested SvelteKit app surfaced a self-reinforcing leak:
localStorage is per-origin, two projects reused 127.0.0.1:5174, and a
React project's leftover cycling session was resumed inside the Svelte
project. Its checkpoints then materialized a ghost session in the new
project's durable store that kept reattaching after every discard, and a
stale adapter module 401'd on live.js, hiding the picker.

Four fixes:

- Server: only session-creating events (generate, steer) may mint a
  journal. Progress events (checkpoints, mount acks, accept/discard) for
  unknown ids are refused with 404 unknown_session and never enqueued, so
  foreign browser state cannot create ghost sessions. Browser sends are
  gated so progress never overtakes its own creating POST (the Go-time
  checkpoint and generate are concurrent fetches; the first sweep caught
  the out-of-order arrival breaking every SvelteKit flow). Steer
  checkpoints now follow the steer event for the same reason.
- Browser: saved sessions carry the server's appRoot; a session stamped
  by another project is dropped at load time. Unstamped legacy state is
  caught by the unknown_session refusal, which clears local state and
  re-arms the picker with an explanatory toast.
- SvelteKit adapter: the layout import carries a token-derived revision
  query so a helper restart changes the module specifier and no Vite
  client/SSR cache can serve an adapter with a rotated-out token;
  live-inject --port reads the running helper's token from server.json
  instead of writing an unauthenticated live.js URL; script load failures
  log an actionable console error; and adapter removal is byte-exact
  (the old regex swallowed the next line's indentation).
- live.mjs resolves surface briefs from appRoot, then contextRoot, then
  repoRoot, matching context.mjs in nested-app repos.

Tests: server unknown-session rejection units, adapter revision/
byte-exact-removal units, and a foreign-session e2e scenario that seeds
another project's localStorage state and asserts it is cleared, no ghost
journal materializes, and picking still works.

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 17:38:46 -07:00
co-authored by Claude Code
parent 69456364b2
commit a83d767cf9
11 changed files with 383 additions and 22 deletions
+54 -5
View File
@@ -21,6 +21,7 @@
const TOKEN = window.__IMPECCABLE_TOKEN__;
const PORT = window.__IMPECCABLE_PORT__;
const APP_ROOT = window.__IMPECCABLE_APP_ROOT__ || null;
if (!TOKEN || !PORT) {
window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
return;
@@ -7132,6 +7133,13 @@
if (currentSessionId) saveSession();
}
// Progress events must never overtake the event that CREATES their session:
// the Go-time checkpoint and the generate POST are concurrent fetches, and
// when the checkpoint lands first the server rightly refuses it as
// unknown_session — which must mean "foreign leftovers", not "you raced
// your own Go click". The gate serializes creation before progress.
let sessionCreationGate = Promise.resolve();
function sendEvent(msg, opts) {
msg.token = TOKEN;
function handleFailure(err) {
@@ -7142,15 +7150,42 @@
console.debug('[impeccable] Dropped optional live event:', err);
return null;
}
return fetch('http://localhost:' + PORT + '/events', {
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
}).then(async res => {
if (res.ok) return res;
const body = await res.json().catch(() => ({}));
// The server refused to journal progress for a session it has never
// seen: this browser is carrying state from another project or a
// wiped store (two apps sharing a localhost port). Continuing to
// report it would freeze the picker behind a session that can never
// complete, so drop the local state and hand the surface back.
if (body.error === 'unknown_session' && msg.type === 'checkpoint'
&& msg.id && msg.id === currentSessionId) {
abandonForeignSession(msg.id);
return null;
}
return handleFailure(new Error(body.error || ('HTTP ' + res.status + ' ' + res.statusText)));
}).catch(handleFailure);
if (msg.type === 'generate' || msg.type === 'steer') {
const creation = doSend();
sessionCreationGate = creation.then(() => {}, () => {});
return creation;
}
return sessionCreationGate.then(doSend);
}
let abandonedForeignSessionId = null;
function abandonForeignSession(sessionId) {
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
abandonedForeignSessionId = sessionId;
console.warn('[impeccable] The live server has no record of session ' + sessionId + '; clearing stale local state.');
markSessionHandled();
cleanup({ instantChrome: true });
showToast('A saved live session belonged to a different project, so it was cleared. Pick an element to start fresh.', 6000);
}
function checkpointPayload(reason) {
@@ -8894,6 +8929,7 @@ void main() {
// it here would overwrite the Go-time value every time state changes.
sessionState.saveSession({
id: currentSessionId,
appRoot: APP_ROOT || undefined,
state,
action: selectedAction,
count: selectedCount,
@@ -8915,7 +8951,17 @@ void main() {
}
function loadSession() {
return sessionState.loadSession();
const saved = sessionState.loadSession();
// localStorage is per-origin, and two projects routinely reuse the same
// localhost port. A saved session stamped with another project's appRoot
// is that project's leftover, never a session this server can complete;
// resuming it freezes the picker behind an unfinishable banner.
if (saved?.appRoot && APP_ROOT && saved.appRoot !== APP_ROOT) {
console.warn('[impeccable] Ignoring saved live session from another project (' + saved.appRoot + ').');
sessionState.clearSession();
return null;
}
return saved;
}
function clearSession() {
@@ -10205,10 +10251,11 @@ void main() {
const id = id8();
steerRequestId = id;
steerPendingMessage = text;
if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true });
lockSteerChat();
scheduleSteerAwaitTimeout(id);
sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href });
// Checkpoints follow the steer event, never precede it: the steer event
// is what creates the session journal server-side, and a checkpoint for
// a not-yet-created session is rejected as unknown_session.
sendEvent({
type: 'steer',
id,
@@ -10216,9 +10263,11 @@ void main() {
pageUrl: location.href,
}).then((res) => {
if (!res) {
sendSteerCheckpoint(id, 'steer_send_failed', { message: text });
unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text });
return;
}
if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true });
sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href });
});
}