Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/memory/architecture/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ session that *expires* on a browser which later gained a platform login, and the
token's server-side validity post-sign-out (no self-service revoke; ent#281's primitive
is per-email).

**One platform credential, one 401 verdict, one handler (#2791).** The paragraph above
describes *which session a tab is in*; this is the layer under it. The platform JWT used
to live in two places that could disagree — the in-memory
`axios.defaults.headers.common['Authorization']` copy and `localStorage['token']` re-read
per request — with **no** `storage` listener anywhere under `src/frontend/src` and three
separate 401 handlers. Because the Workspace opens in its own tab (ent#456) and polls
every 20s, a stale Workspace tab could log a freshly re-established platform session out
within seconds: its poll went out on the OLD token, 401'd, and the handler called
`authStore.logout()`, deleting the NEW session's token. `utils/platformSession.js` is now
the only reader (`readStoredToken`), the only verdict (`sessionLostVerdict` →
`ignore | stale | logout`, where **stale** means *the credential that failed has already
been replaced, so adopt the current session rather than destroy it*) and the only handler
registry; `main.js` installs a global axios **request** interceptor so every bare-`axios`
caller derives the header per request, and a `storage` listener so a login or logout in
one tab reaches every other. The `axios.defaults` copy is written nowhere — a **tree-wide**
source guard says so, because the first cut of this fix guarded `auth.js` alone while
`App.vue` still wrote it on every boot, and axios merges that default into the request
*before* the interceptor chain runs, so the per-request rebuild was inert for the life of
the tab (the merge-train review's C1); the two sync actions and `logout()` delete it as a
belt. The reaction itself (`reactToPlatformUnauthorized`), the storage listener
(`reactToStorageEvent`) and the request rebuild (`applyRequestCredential`) are functions
of their collaborators, executed by unit tests with fakes — `main.js` is wiring only. The
logout revoke carries its token **explicitly**, because #2258's clear-before-revoke
ordering means storage is already empty by then. The Workspace veto in the verdict reads
the **per-tab** portal token from the store, not shared `localStorage`, so a client
signing in in another tab cannot strand an operator's Workspace tab on an expired JWT.
Full model and the verdict table:
[workspace-session-signout.md](../feature-flows/workspace-session-signout.md).

**Membership is a DB fact; container state is a projection onto the card (#2196).** The
roster is built from `agent_ownership` / `agent_sharing` and is **never** filtered by
whether a container exists. A live ownership row with no container is a routine state
Expand Down
100 changes: 98 additions & 2 deletions docs/memory/feature-flows/workspace-session-signout.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,98 @@ form simply reappeared, indistinguishable from "you were never signed in".
**Degradation:** if `sessionStorage` is unavailable (private mode), the marker reads
as absent — pre-#2261 behaviour, rather than a workspace nobody can enter.

## One credential, one verdict, one handler (#2791)

Everything above is about *which session a tab is in*. #2791 is the layer under
it: **where the credential lives, and who is allowed to end it.**

One browser used to hold the platform JWT in two places that could disagree —
the in-memory `axios.defaults.headers.common['Authorization']` written once at
login by `auth.js::setupAxiosAuth`, and `localStorage['token']` re-read per
request by `api.js` — with no cross-tab listener anywhere under
`src/frontend/src`, and three separate 401 handlers. The Workspace made it bite
hardest because it opens in its own tab (ent#456) and polls every 20s.

**The reported symptom.** Log out and log back in on the main app with a
Workspace tab open from the previous session. That tab still holds the OLD JWT;
its next poll 401s; the handler calls `authStore.logout()`, which removes
`localStorage['token']` — *the token the re-login had just written*. The main
tab's next request finds nothing and hard-redirects to `/login`. A stale tab
killed a fresh session, and the handler never asked whether the credential that
failed was still the current one.

### The three things that are now singular

**One source.** `utils/platformSession.js::readStoredToken()` is the only reader
— every former `localStorage.getItem('token')` site (22 of them, incl. the
WebSocket and EventSource ones that cannot use an interceptor) now goes through
it, and a tree-wide guard keeps it that way. The `axios.defaults` copy is gone:
`setupAxiosAuth` is a documented no-op, `App.vue`'s boot-time write — the one
that survived the first cut and made everything below inert, since axios merges
that default into the request BEFORE the interceptor chain runs — is removed,
and the guard for "nobody writes it" walks `src/frontend/src/**`, not one file.
`main.js` installs a global axios **request** interceptor
(`applyRequestCredential`) that rebuilds the header from storage on every
request — so all ~368 bare-`axios` call sites get the current credential without
being rewritten, and one added tomorrow cannot forget to opt in. An **explicit** header on the config still wins, and exactly
one caller needs that: the logout revoke, which must carry a token storage has
already dropped (the #2258 ordering above is unchanged, so the token is captured
*before* the clear and passed *after* it — otherwise #187 silently stopped
revoking anything).

**One verdict.** `sessionLostVerdict()` is a pure function returning
`ignore | stale | logout`, and it replaced a predicate that had been hand-copied
into `api.js`, `main.js` and `portalHttp` and drifted three ways:

| situation | verdict |
|---|---|
| already on `/login`, `/setup`, `/m` | `ignore` |
| the failed token is **not** the stored one | `stale` — adopt the current session, never destroy it |
| no stored token, on the Workspace | `ignore` (an ordinary external client) |
| no stored token, anywhere else | `logout` |
| on the Workspace **and** a portal token is live | `ignore` — **AC #5**: a client whose browser holds a dead operator JWT is no longer thrown onto the operator login by `initializeAuth`'s `fetchUserProfile` |
| otherwise | `logout` |

The `stale` arm is the fix for the reported symptom. The Workspace veto is scoped
by path *as well as* by portal token deliberately: off the Workspace the surface
is an operator one, so an expired operator JWT still bounces there even with a
stray portal token — this change does not widen that.

**One handler.** `setPlatformUnauthorizedHandler` / `notifyPlatformUnauthorized`
in `utils/platformSession.js`. The reaction is `reactToPlatformUnauthorized(error,
deps)` in the same file — it takes the store's two sync actions, `logout` and the
router push as arguments, so the unit suite executes it with fakes (the first cut
kept it inline in `main.js` and pinned it by regex; a mutation restoring the
reported bug stayed green). `main.js` registers a thin adapter that supplies the
real collaborators and **returns** the navigation promise, so a rejected
redundant navigation is absorbed by `notifyPlatformUnauthorized` rather than
escaping as an unhandled rejection. `api.js`, the global interceptor and
`portalHttp` all report to it. The verdict's Workspace veto
(`portalTokenPresent`) is read from the **per-tab** `clientPortal` store — the
same gate `portalHttp` uses — never from shared `localStorage`, or a client's
login in another tab would tell an operator's Workspace tab to `ignore` its own
expired JWT.
`clientPortal.js` keeps `isPlatformSession` as its local gate — not redundant,
because it is the only thing that knows this tab's client session was
*suppressed* (#2261's `platformFallbackSuppressed`), which no amount of reading
localStorage reconstructs.

### Cross-tab sync

`main.js` listens for `storage` and hands the event to `reactToStorageEvent`
(executed by the unit suite), which acts on the token key, the user key — a
sibling login writes `token` first and `auth0_user` a tick later — and a
whole-storage clear. A sibling tab logging in → `adoptStoredSession()` (converge, re-fetch the profile, reset
`profileVerified` so role-gated UI stays closed until *this* token's profile
lands). A sibling logging out → `applySessionEndedElsewhere()`, which drops the
in-memory mirror only: it fires no second server revoke for an already-revoked
token, and writes nothing to storage, because N background tabs reacting to one
event would otherwise each clear it again.

Neither branch navigates. A background tab pushing `/login` is the noise this
issue reports; the visible tab converges through the router guard and its next
request, both of which read the state these set.

## Stated residuals (not hidden)

- ~~**Client-session expiry with a later platform login** still falls back to the
Expand All @@ -182,8 +274,12 @@ as absent — pre-#2261 behaviour, rather than a workspace nobody can enter.
## Files

- `src/frontend/src/stores/clientPortal.js` — `signOutEverywhere()`, `PLATFORM_LOGIN_ROUTE`
- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering
- `src/frontend/src/utils/platformSession.js` — #2791: the one reader, the one verdict, the one handler registry
- `src/frontend/src/stores/auth.js` — `logout()` local-clear-before-revoke ordering; `adoptStoredSession` / `applySessionEndedElsewhere`
- `src/frontend/src/main.js` — global request interceptor, the registered reaction, the `storage` listener
- `src/frontend/src/api.js` — reports to the shared handler (no private predicate, no hard reload)
- `src/frontend/src/views/Portal.vue` — `onSignOut`, `signingOut` frame
- `src/frontend/src/components/portal/PortalSidebar.vue` — footer button + caption
- `src/frontend/src/components/portal/portalUtils.js` — `signOutLabelFor`
- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js`
- `src/frontend/tests/unit/workspaceSession.spec.js`, `workspaceSignOut.spec.js`,
`platformSessionVerdict.spec.js`, `platformSessionSync.spec.js`
17 changes: 12 additions & 5 deletions src/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
<script setup>
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useAuthStore } from './stores/auth'
import { readStoredToken } from './utils/platformSession'
import { useThemeStore } from './stores/theme'
import { useWebSocket } from './utils/websocket'
import HelpChatWidget from './components/HelpChatWidget.vue'
Expand All @@ -55,13 +55,20 @@ onMounted(async () => {
// Initialize theme immediately to prevent flash
themeStore.initTheme()

// Check if user is authenticated
const token = localStorage.getItem('token')
// Seed the store SYNCHRONOUSLY from storage so router guards keying on
// `isAuthenticated` are satisfied before `initializeAuth`'s first await lands;
// `initializeAuth` (dispatched from main.js) restores the user and verifies
// the profile. This block used to ALSO write
// `axios.defaults.headers.common['Authorization']` — the second credential
// source #2791 removes. Axios merges that default into every request BEFORE
// the interceptor chain runs, so the copy written here won over storage for
// the life of the tab and the per-request interceptor in `main.js` was inert
// on every boot with a token. Requests get their credential from
// `applyRequestCredential` now; nothing writes the default.
const token = readStoredToken()
if (token) {
authStore.token = token
authStore.isAuthenticated = true
// Set axios default authorization header
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
// Connect to WebSocket for real-time updates
connect()
}
Expand Down
36 changes: 12 additions & 24 deletions src/frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import axios from 'axios'
import { notifyPlatformUnauthorized, readStoredToken } from '@/utils/platformSession'

// PERF-269: In-flight request deduplication map
// Key: "GET:/api/agents/context-stats" → Value: Promise
Expand All @@ -20,7 +21,7 @@ const api = axios.create({
// Add auth token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
const token = readStoredToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
Expand All @@ -32,32 +33,19 @@ api.interceptors.request.use(
)

// Handle auth errors
//
// #2791: this used to be the THIRD logout implementation — it removed `token`
// (leaving `auth0_user` behind), hard-reloaded to `/login` with no server-side
// revoke, and carried its own copy of the bounce predicate. What "logged out"
// meant depended on which transport happened to 401 first.
//
// It now reports to the one handler (`utils/platformSession.js`), which owns the
// verdict AND the reaction — including the `stale` arm, without which this
// interceptor would still delete a freshly re-logged-in session's token.
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// #138: an external client on the workspace manages its own
// (verified-email) session and must never be bounced to the operator
// /login by a stale operator JWT — let that code handle its own 401.
// ent#357: an internal user's workspace session IS the platform session,
// so they DO get bounced. Same path, two session kinds — discriminate on
// the portal token, not the URL.
const path = window.location.pathname
// Who gets bounced is decided by the PLATFORM token, not the portal one
// (/review I1). Reading the portal token here made the answer depend on
// timing: `fetchRoster`'s 401 handler calls `signOut()`, which removes it,
// so a second concurrent 401 saw no portal token and threw an external
// client onto the operator /login instead of the workspace sign-in form.
// "Does this browser hold a platform session that just expired?" is the
// actual question, and it has a stable answer.
const onWorkspace = path.startsWith('/workspace') || path.startsWith('/portal')
const internalSession = !!localStorage.getItem('token')
if (!onWorkspace || internalSession) {
// Token expired or invalid - redirect to login
localStorage.removeItem('token')
window.location.href = '/login'
}
}
if (error.response?.status === 401) notifyPlatformUnauthorized(error)
return Promise.reject(error)
}
)
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/components/AgentListPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@

<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { readStoredToken } from '../utils/platformSession'
import { formatCostCompact } from '../composables/useFormatters'
import { useNotification } from '../composables/useNotification'
import { useAgentsStore } from '../stores/agents'
Expand Down Expand Up @@ -1157,7 +1158,7 @@ async function handleReadOnlyToggle(agent) {
const newState = !agent.read_only_enabled

try {
const token = localStorage.getItem('token')
const token = readStoredToken()
const response = await axios.put(`/api/agents/${agent.name}/read-only`, {
enabled: newState
}, {
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/components/AgentTerminal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@

<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { readStoredToken } from '@/utils/platformSession'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
Expand Down Expand Up @@ -320,7 +321,7 @@ function connect() {
terminal.write(`\x1b[33mConnecting to ${props.agentName}...\x1b[0m\r\n`)

// Get token from localStorage
const token = localStorage.getItem('token')
const token = readStoredToken()
if (!token) {
errorMessage.value = 'Not authenticated. Please log in.'
connectionStatus.value = 'disconnected'
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/components/HostTelemetry.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import SparklineChart from './SparklineChart.vue'
import { readStoredToken } from '../utils/platformSession'

// Relative, same-origin API base (nginx/Vite proxy) — matches api.js baseURL: ''.
// Intentionally not an env var: VITE_API_BASE was never set anywhere, so this was
Expand Down Expand Up @@ -30,7 +31,7 @@ function initHistory() {

async function fetchStats() {
try {
const token = localStorage.getItem('token')
const token = readStoredToken()
if (!token) return

const headers = { Authorization: `Bearer ${token}` }
Expand Down
7 changes: 4 additions & 3 deletions src/frontend/src/components/process/TemplateSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import axios from 'axios'
import { readStoredToken } from '../../utils/platformSession'
import {
PlusIcon,
CheckCircleIcon,
Expand Down Expand Up @@ -258,7 +259,7 @@ async function fetchTemplates() {
loading.value = true
loadError.value = ''
try {
const token = localStorage.getItem('token')
const token = readStoredToken()
const response = await axios.get('/api/process-templates', {
headers: { Authorization: `Bearer ${token}` },
})
Expand All @@ -273,7 +274,7 @@ async function fetchTemplates() {

async function fetchCategories() {
try {
const token = localStorage.getItem('token')
const token = readStoredToken()
const response = await axios.get('/api/process-templates/categories', {
headers: { Authorization: `Bearer ${token}` },
})
Expand All @@ -297,7 +298,7 @@ function getCategoryBadgeClass(category) {

async function showPreview(template) {
try {
const token = localStorage.getItem('token')
const token = readStoredToken()
const response = await axios.get(`/api/process-templates/${template.id}`, {
headers: { Authorization: `Bearer ${token}` },
})
Expand Down
Loading
Loading