Operator session expires silently — UI keeps 401ing instead of bouncing to /unlock #108

Closed
opened 2026-09-04 16:03:34 +02:00 by lz · 0 comments
Owner

Symptom

After Nexus has been left open for a while, operations stop working. Nothing tells the operator why — buttons fail, the terminal shows "reconnect failed / the workspace may not be running". Logging out and logging back in restores everything immediately.

Diagnosis

Confirmed in the code; two independent defects compound.

1. The session TTL is absolute, not sliding

SessionStore.create() stamps expiresAt = Date.now() + ttl once and get() never extends it (nexus/src/lib/server/auth/session.ts:19, :26). With session_ttl_hours defaulting to 12 (settings/registry.ts:107), an operator who has the tab open and is actively using it is logged out 12 hours after unlocking, regardless of activity.

The store is also in-memory, so a Nexus restart drops every session — same symptom, no TTL involved.

2. A 401 is never surfaced or acted on

Once the session is gone, hooks.server.ts:29 returns 401 {"error":"locked"} for every /api/* route. On the client:

  • call() in nexus/src/lib/api/client.ts:42 throws ApiError(401, 'locked'). Nothing anywhere catches 401 specifically — there is no global handler that redirects to /unlock, no grep hit for it outside the server routes.
  • The redirect logic that would fix this lives in +layout.server.ts, which only runs on a navigation. An SPA that is just polling never re-runs it, so the app sits there indefinitely in a broken state.
  • The terminal WebSocket is rejected 401 by terminal/upgrade.ts:97, but a browser exposes a rejected handshake as code 1006 with an empty reason, so SessionTerminal.svelte falls back to the hedged message "the workspace may not be running" — which points the operator at the wrong cause entirely.

So the session dies, every request 401s, and the only feedback is a misleading terminal message.

Suggested fix

Smallest change that addresses the reported symptom:

  1. Sliding expiry — extend expiresAt in SessionStore.get() on each hit, and re-issue the cookie (maxAge) from a hook so the browser cookie tracks the server session. An idle timeout stays meaningful; an active operator is never logged out mid-work.
  2. Handle 401 in call() — on 401 locked, redirect to /unlock (goto('/unlock') / hard location.assign), which is the state the app is actually in. This also covers the Nexus-restart case that no TTL change can fix.

Optional follow-up: pair with #49 so the post-unlock redirect returns to the page the operator was on.

Not covered here

Whether the terminal overlay should distinguish 401 from 409 — it genuinely cannot from the WS close code alone. Once (2) lands, the app will have navigated to /unlock before the operator reads that overlay, so the misleading string stops being reachable in this scenario.

## Symptom After Nexus has been left open for a while, operations stop working. Nothing tells the operator why — buttons fail, the terminal shows "reconnect failed / the workspace may not be running". Logging out and logging back in restores everything immediately. ## Diagnosis Confirmed in the code; two independent defects compound. ### 1. The session TTL is absolute, not sliding `SessionStore.create()` stamps `expiresAt = Date.now() + ttl` once and `get()` never extends it (`nexus/src/lib/server/auth/session.ts:19`, `:26`). With `session_ttl_hours` defaulting to 12 (`settings/registry.ts:107`), an operator who has the tab open and is actively using it is logged out 12 hours after unlocking, regardless of activity. The store is also in-memory, so a Nexus restart drops every session — same symptom, no TTL involved. ### 2. A 401 is never surfaced or acted on Once the session is gone, `hooks.server.ts:29` returns `401 {"error":"locked"}` for every `/api/*` route. On the client: - `call()` in `nexus/src/lib/api/client.ts:42` throws `ApiError(401, 'locked')`. Nothing anywhere catches 401 specifically — there is no global handler that redirects to `/unlock`, no `grep` hit for it outside the server routes. - The redirect logic that *would* fix this lives in `+layout.server.ts`, which only runs on a **navigation**. An SPA that is just polling never re-runs it, so the app sits there indefinitely in a broken state. - The terminal WebSocket is rejected 401 by `terminal/upgrade.ts:97`, but a browser exposes a rejected handshake as code 1006 with an empty reason, so `SessionTerminal.svelte` falls back to the hedged message "the workspace may not be running" — which points the operator at the wrong cause entirely. So the session dies, every request 401s, and the only feedback is a misleading terminal message. ## Suggested fix Smallest change that addresses the reported symptom: 1. **Sliding expiry** — extend `expiresAt` in `SessionStore.get()` on each hit, and re-issue the cookie (`maxAge`) from a hook so the browser cookie tracks the server session. An idle timeout stays meaningful; an active operator is never logged out mid-work. 2. **Handle 401 in `call()`** — on `401 locked`, redirect to `/unlock` (`goto('/unlock')` / hard `location.assign`), which is the state the app is actually in. This also covers the Nexus-restart case that no TTL change can fix. Optional follow-up: pair with #49 so the post-unlock redirect returns to the page the operator was on. ## Not covered here Whether the terminal overlay should distinguish 401 from 409 — it genuinely cannot from the WS close code alone. Once (2) lands, the app will have navigated to `/unlock` before the operator reads that overlay, so the misleading string stops being reachable in this scenario.
lz closed this issue 2026-09-05 12:40:34 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#108
No description provided.