authenticate the chat socket the same way every request is authenticated

Reported after the revert: the app loads, the old layout is back, history lists — and the socket
never reaches connected.

The two doors disagreed. `createClient` accepts a token from seven places: window.officerBearerToken,
two body datasets, an `?officerToken=` query param, PERTENTO_EDITOR_AUTH_TOKEN, localStorage and
sessionStorage. The chat socket url read exactly one of them, `localStorage.BEARER_TOKEN`, so a
token held anywhere else authenticated every HTTP request and left the WebSocket with a bare
`?token=`.

That failure is silent and reads as a dead server: verified here, an empty token closes with 1002
"Expected 101 status code", and the hook's retry loop repeats it forever. Nothing logs a missing
credential, so the app looks fine in every way except the one that matters.

Resolution is now one exported function, `resolveBearerToken`, used by both. The point is that it
cannot be re-spelled: this bug is the second spelling drifting from the first.

Predates the tabs work and survived reverting it, which is the evidence it was never a panes bug.

Not fixed here, same shape, left alone deliberately: Terminal, Desktop, AudioStreamPlayer, the
pipeline and task runners, JobDetail and EmailList all build socket or fetch urls from
`localStorage.BEARER_TOKEN` directly and will fail identically for the same user.

Typecheck clean. 600 pass, 2 fail — cliamp and pty, unchanged and unrelated. Not verified in a
browser; Andre has the only client that reproduces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:33:45 +01:00
co-authored by Claude Opus 5
parent 0a4ff548b9
commit 52d567874f
3 changed files with 26 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
export { useGlobal } from './useGlobal';
export { usePanelChannel } from './usePanelChannel';
export { useClient, createClient } from './useClient';
export { useClient, createClient, resolveBearerToken } from './useClient';
export {
useConnections,
listConnections,
+20 -8
View File
@@ -8,15 +8,27 @@ let theToken: string | null = null;
* caller. Naming both is how a panel talks to ANOTHER Officer without any global being switched, which
* is what lets two panes hold two live conversations on two machines (see `connections.ts`).
*/
/**
* The session token for THIS origin, from every place one is allowed to live.
*
* Exported because it must not be re-spelled anywhere. A caller that reads only
* `localStorage.BEARER_TOKEN` — which the chat socket url did — authenticates for HTTP and fails for
* WebSockets the moment the token is held anywhere else: an embedded host setting
* `window.officerBearerToken`, a `?officerToken=` link, or sessionStorage. The app then loads, renders
* and lists history perfectly while the socket is refused with a 1002 and retries forever, which reads
* as a dead server rather than a missing credential.
*/
export const resolveBearerToken = (): string | null =>
window.officerBearerToken ||
document.body.dataset['officerBearerToken'] ||
document.body.dataset['bearerToken'] ||
new URL(window.location.href).searchParams.get('officerToken') ||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
localStorage.getItem('BEARER_TOKEN') ||
sessionStorage.getItem('BEARER_TOKEN');
export const createClient = (baseUrl: string = '/api', token?: string | null) => {
const lsToken =
window.officerBearerToken ||
document.body.dataset['officerBearerToken'] ||
document.body.dataset['bearerToken'] ||
new URL(window.location.href).searchParams.get('officerToken') ||
localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ||
localStorage.getItem('BEARER_TOKEN') ||
sessionStorage.getItem('BEARER_TOKEN');
const lsToken = resolveBearerToken();
theToken = lsToken;
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useServerClient, chatSocketUrl } from 'hooks/useServerClient';
import { resolveBearerToken } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
import { spliceRunningTasks } from '../apps/Chat/running-tasks';
@@ -179,7 +180,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// Null when a named server is unknown, which opens no socket rather than dialling this origin under
// another server's name — the failure that would put one machine's turn in another's pane.
const wsUrl = chatSocketUrl(serverId, localStorage.getItem('BEARER_TOKEN'));
// The SAME resolution the HTTP client uses. Reading localStorage directly here meant a token held
// anywhere else authenticated every request and left the socket with `?token=`, refused 1002 and
// retrying forever — an app that loads and lists history but never connects.
const wsUrl = chatSocketUrl(serverId, resolveBearerToken());
function flushStreaming() {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);