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
+21
View File
@@ -91,6 +91,12 @@ function resolveProjectContext() {
}
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
// The browser events allowed to mint a NEW session journal. `generate` starts
// a variant session at Go; `steer` mints its own request id. Every other
// id-carrying event must land on an existing session (see the unknown_session
// gate in the /events handler).
const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']);
// The browser checkpoints for several unrelated reasons (see checkpointPayload
// in live-browser.js). Only these two report that variant availability changed,
// and only they may drive variant_progress / the *_reviewable phases.
@@ -730,6 +736,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
port: state.port,
vocabulary: LIVE_COMMANDS,
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
appRoot: process.cwd(),
parts,
});
res.writeHead(200, {
@@ -1020,6 +1027,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ ok: true }));
return;
}
// Only the events that START a session may create its journal.
// Everything else (checkpoints, mount acks, accept/discard) must
// reference a session THIS store already knows: appendEvent creates a
// journal for any id it is handed, so without this gate a browser
// resuming another project's session from per-origin storage (two
// apps sharing a localhost port) materializes a ghost session here
// that keeps reattaching after every discard.
if (msg.id && state.sessionStore
&& !SESSION_CREATING_EVENT_TYPES.has(msg.type)
&& !state.sessionStore.has(msg.id)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'unknown_session', id: msg.id }));
return;
}
const missedCompletion = detectMissedGenerationCompletion(msg);
if (state.sessionStore && msg.id) {
try {