diff --git a/src/workspaces/hooks/src/index.ts b/src/workspaces/hooks/src/index.ts index 57e71466..6be88e50 100644 --- a/src/workspaces/hooks/src/index.ts +++ b/src/workspaces/hooks/src/index.ts @@ -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, diff --git a/src/workspaces/hooks/src/useClient.ts b/src/workspaces/hooks/src/useClient.ts index 81c13655..b72dacda 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -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; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 57b76ace..3e15c637 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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);