mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
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:
co-authored by
Claude Code
parent
69456364b2
commit
a83d767cf9
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user