diff --git a/src/server/__tests__/gateway-capabilities.test.ts b/src/server/__tests__/gateway-capabilities.test.ts
index f954ceeca..9404a9269 100644
--- a/src/server/__tests__/gateway-capabilities.test.ts
+++ b/src/server/__tests__/gateway-capabilities.test.ts
@@ -83,6 +83,7 @@ describe('gateway-capabilities', () => {
kanban: false,
dashboard: {
available: false,
+ authenticated: false,
url: 'http://127.0.0.1:9119',
},
},
@@ -113,6 +114,7 @@ describe('gateway-capabilities', () => {
kanban: false,
dashboard: {
available: false,
+ authenticated: false,
url: 'http://127.0.0.1:9119',
},
},
@@ -320,6 +322,209 @@ describe('gateway-capabilities', () => {
expect(caps.conductor).toBe(true)
})
+ describe('dashboard reachable vs authenticated split', () => {
+ const routeFetch = (sessionsStatus: number) =>
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input)
+ if (url === 'http://dashboard.test/api/status') {
+ return new Response(JSON.stringify({ version: '0.12.0' }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+ if (url === 'http://dashboard.test/') {
+ // Cookie-gated dashboards serve the login shell — no inline token.
+ return new Response('
login', {
+ headers: { 'content-type': 'text/html' },
+ })
+ }
+ if (url.startsWith('http://dashboard.test/api/sessions')) {
+ return new Response(
+ JSON.stringify(
+ sessionsStatus === 200
+ ? { sessions: [], total: 0 }
+ : {
+ error: 'unauthenticated',
+ detail: 'Unauthorized',
+ reason: 'no_cookie',
+ login_url: '/login',
+ },
+ ),
+ {
+ status: sessionsStatus,
+ headers: { 'content-type': 'application/json' },
+ },
+ )
+ }
+ if (url === 'http://gateway.test/v1/chat/completions')
+ return new Response('', { status: 405 })
+ if (url === 'http://gateway.test/api/sessions/__probe__/chat/stream')
+ return new Response('', { status: 404 })
+ if (url.endsWith('/api/mcp')) return new Response('', { status: 404 })
+ if (url === 'http://dashboard.test/api/conductor/missions')
+ return new Response('', { status: 404 })
+ if (url === 'http://dashboard.test/api/plugins/kanban/board')
+ return new Response('', { status: 404 })
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ })
+
+ it('classifies a cookie-gated dashboard as reachable but NOT authenticated, keeping sessions via the gateway', async () => {
+ process.env.HERMES_API_URL = 'http://gateway.test'
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ vi.stubGlobal('fetch', routeFetch(401))
+
+ const mod = await loadMod()
+ const caps = await mod.probeGateway({ force: true })
+
+ expect(caps.dashboard.available).toBe(true)
+ expect(caps.dashboard.authenticated).toBe(false)
+ // Gateway /api/sessions probe (generic 200 fallback) keeps the composite
+ // capability alive without the dashboard.
+ expect(caps.sessions).toBe(true)
+ })
+
+ it('marks the dashboard authenticated when the sessions probe succeeds', async () => {
+ process.env.HERMES_API_URL = 'http://gateway.test'
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ vi.stubGlobal('fetch', routeFetch(200))
+
+ const mod = await loadMod()
+ const caps = await mod.probeGateway({ force: true })
+
+ expect(caps.dashboard.available).toBe(true)
+ expect(caps.dashboard.authenticated).toBe(true)
+ })
+
+ it('markDashboardUnauthenticated degrades a previously authenticated dashboard', async () => {
+ process.env.HERMES_API_URL = 'http://gateway.test'
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ vi.stubGlobal('fetch', routeFetch(200))
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const mod = await loadMod()
+ await mod.probeGateway({ force: true })
+ expect(mod.getCapabilities().dashboard.authenticated).toBe(true)
+
+ mod.markDashboardUnauthenticated()
+ expect(mod.getCapabilities().dashboard.authenticated).toBe(false)
+ expect(mod.getCapabilities().dashboard.available).toBe(true)
+ warnSpy.mockRestore()
+ })
+ })
+
+ describe('dashboard password-login (Option d)', () => {
+ afterEach(() => {
+ delete process.env.HERMES_USERNAME
+ delete process.env.HERMES_PASSWORD
+ delete process.env.HERMES_DASHBOARD_USERNAME
+ delete process.env.HERMES_DASHBOARD_PASSWORD
+ })
+
+ const routeFetch = (opts: { loginStatus: number }) => {
+ const calls = { login: 0 }
+ const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input)
+ if (url === 'http://dashboard.test/api/status') {
+ return new Response(JSON.stringify({ version: '0.18.0' }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+ if (url === 'http://dashboard.test/') {
+ return new Response('login form', {
+ headers: { 'content-type': 'text/html' },
+ })
+ }
+ if (url === 'http://dashboard.test/auth/password-login') {
+ calls.login++
+ if (opts.loginStatus !== 200) {
+ return new Response(JSON.stringify({ error: 'invalid' }), {
+ status: opts.loginStatus,
+ headers: { 'content-type': 'application/json' },
+ })
+ }
+ return new Response(JSON.stringify({ next: '/' }), {
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ 'set-cookie': 'hermes_session_at=tok123; Path=/; HttpOnly',
+ },
+ })
+ }
+ if (url.startsWith('http://dashboard.test/api/sessions')) {
+ const cookie = new Headers(init?.headers).get('cookie') || ''
+ return cookie.includes('hermes_session_at')
+ ? new Response(JSON.stringify({ sessions: [], total: 0 }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ : new Response(
+ JSON.stringify({ error: 'unauthenticated', reason: 'no_cookie' }),
+ { status: 401, headers: { 'content-type': 'application/json' } },
+ )
+ }
+ if (url === 'http://gateway.test/v1/chat/completions')
+ return new Response('', { status: 405 })
+ if (url === 'http://gateway.test/api/sessions/__probe__/chat/stream')
+ return new Response('', { status: 404 })
+ if (url.endsWith('/api/mcp')) return new Response('', { status: 404 })
+ if (url === 'http://dashboard.test/api/conductor/missions')
+ return new Response('', { status: 404 })
+ if (url === 'http://dashboard.test/api/plugins/kanban/board')
+ return new Response('', { status: 404 })
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { 'content-type': 'application/json' },
+ })
+ })
+ return { fn, calls }
+ }
+
+ it('logs into the basic provider, reuses the cookie, and reports authenticated', async () => {
+ process.env.HERMES_API_URL = 'http://gateway.test'
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ process.env.HERMES_DASHBOARD_USERNAME = 'admin'
+ process.env.HERMES_DASHBOARD_PASSWORD = 'pw-123456789012345678'
+ const { fn, calls } = routeFetch({ loginStatus: 200 })
+ vi.stubGlobal('fetch', fn)
+
+ const mod = await loadMod()
+ const caps = await mod.probeGateway({ force: true })
+
+ expect(caps.dashboard.available).toBe(true)
+ expect(caps.dashboard.authenticated).toBe(true)
+ expect(calls.login).toBeGreaterThanOrEqual(1)
+ // POST body carries the documented contract
+ const loginCall = fn.mock.calls.find(
+ ([u]) => String(u) === 'http://dashboard.test/auth/password-login',
+ )
+ const body = JSON.parse(String((loginCall?.[1] as RequestInit)?.body))
+ expect(body).toMatchObject({ provider: 'basic', username: 'admin' })
+ })
+
+ it('degrades to unauthenticated when login is rejected', async () => {
+ process.env.HERMES_API_URL = 'http://gateway.test'
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ process.env.HERMES_DASHBOARD_PASSWORD = 'wrong-pw-000000000000'
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const { fn } = routeFetch({ loginStatus: 401 })
+ vi.stubGlobal('fetch', fn)
+
+ const mod = await loadMod()
+ const caps = await mod.probeGateway({ force: true })
+
+ expect(caps.dashboard.available).toBe(true)
+ expect(caps.dashboard.authenticated).toBe(false)
+ warn.mockRestore()
+ })
+
+ it('dashboardLogin returns empty when no password is configured', async () => {
+ process.env.HERMES_DASHBOARD_URL = 'http://dashboard.test'
+ vi.stubGlobal('fetch', vi.fn())
+ const mod = await loadMod()
+ await expect(mod.dashboardLogin()).resolves.toBe('')
+ })
+ })
+
describe('isLocalhostDeployment', () => {
afterEach(() => {
delete process.env.HOST
diff --git a/src/server/__tests__/sessions-routing.test.ts b/src/server/__tests__/sessions-routing.test.ts
new file mode 100644
index 000000000..74b383576
--- /dev/null
+++ b/src/server/__tests__/sessions-routing.test.ts
@@ -0,0 +1,189 @@
+/**
+ * Session routing + shape normalization tests.
+ *
+ * Covers the remote-deployment failure mode where the dashboard is reachable
+ * (/api/status is public) but its session APIs are cookie-gated and return
+ * 401 {"error":"unauthenticated","reason":"no_cookie"}: the workspace must
+ * fall back to the gateway instead of surfacing the 401 in the Sessions tab,
+ * and list-returning adapters must never return undefined (callers .map).
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const GATEWAY = 'http://gateway.test:8642'
+const DASHBOARD = 'http://dashboard.test:9119'
+
+const { state, dashboardFetchMock, markMock } = vi.hoisted(() => {
+ const state = {
+ dashboard: { available: false, authenticated: false, url: 'http://dashboard.test:9119' },
+ }
+ return {
+ state,
+ dashboardFetchMock: vi.fn(),
+ markMock: vi.fn(() => {
+ state.dashboard.authenticated = false
+ }),
+ }
+})
+
+vi.mock('../gateway-capabilities', () => ({
+ BEARER_TOKEN: '',
+ CLAUDE_API: 'http://gateway.test:8642',
+ CLAUDE_DASHBOARD_URL: 'http://dashboard.test:9119',
+ SESSIONS_API_UNAVAILABLE_MESSAGE: 'sessions unavailable',
+ dashboardFetch: dashboardFetchMock,
+ ensureGatewayProbed: vi.fn(),
+ probeGateway: vi.fn(),
+ getCapabilities: () => ({ dashboard: state.dashboard }),
+ markDashboardUnauthenticated: markMock,
+}))
+
+const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }))
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+}
+
+const NO_COOKIE_BODY = {
+ error: 'unauthenticated',
+ detail: 'Unauthorized',
+ reason: 'no_cookie',
+ login_url: '/login',
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', fetchMock)
+ state.dashboard.available = false
+ state.dashboard.authenticated = false
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+async function importClaudeApi() {
+ return import('../claude-api')
+}
+
+describe('listSessions routing', () => {
+ it('uses the gateway when the dashboard is reachable but not authenticated', async () => {
+ state.dashboard.available = true
+ state.dashboard.authenticated = false
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ object: 'list', data: [{ id: 's1' }], has_more: false }),
+ )
+
+ const { listSessions } = await importClaudeApi()
+ const sessions = await listSessions(3, 0)
+
+ expect(sessions).toEqual([{ id: 's1' }])
+ expect(dashboardFetchMock).not.toHaveBeenCalled()
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${GATEWAY}/api/sessions?limit=3&offset=0`,
+ expect.anything(),
+ )
+ })
+
+ it('falls back to the gateway when a dashboard call 401s mid-flight, and degrades the capability', async () => {
+ state.dashboard.available = true
+ state.dashboard.authenticated = true
+ dashboardFetchMock.mockResolvedValueOnce(jsonResponse(NO_COOKIE_BODY, 401))
+ // A fresh Response per call — this test invokes listSessions() twice, and a
+ // Response body can only be read once.
+ fetchMock.mockImplementation(async () =>
+ jsonResponse({ object: 'list', data: [{ id: 's1' }] }),
+ )
+
+ const { listSessions } = await importClaudeApi()
+ const sessions = await listSessions()
+
+ expect(sessions).toEqual([{ id: 's1' }])
+ expect(markMock).toHaveBeenCalledTimes(1)
+
+ // Second call: capability degraded — dashboard must be skipped entirely.
+ dashboardFetchMock.mockClear()
+ await listSessions()
+ expect(dashboardFetchMock).not.toHaveBeenCalled()
+ })
+
+ it('uses the dashboard when authenticated and normalizes {sessions:[...]}', async () => {
+ state.dashboard.available = true
+ state.dashboard.authenticated = true
+ dashboardFetchMock.mockResolvedValueOnce(
+ jsonResponse({ sessions: [{ id: 'd1' }], total: 1, limit: 50, offset: 0 }),
+ )
+
+ const { listSessions } = await importClaudeApi()
+ expect(await listSessions()).toEqual([{ id: 'd1' }])
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rethrows non-auth dashboard errors instead of silently falling back', async () => {
+ state.dashboard.available = true
+ state.dashboard.authenticated = true
+ dashboardFetchMock.mockResolvedValueOnce(
+ new Response('boom', { status: 500 }),
+ )
+
+ const { listSessions } = await importClaudeApi()
+ await expect(listSessions()).rejects.toThrow(/500/)
+ expect(markMock).not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('response shape normalization (never undefined)', () => {
+ it('accepts the OpenAI-compat gateway shape {object:"list", data:[...]}', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ object: 'list', data: [{ id: 'a' }, { id: 'b' }] }),
+ )
+ const { listSessions } = await importClaudeApi()
+ expect(await listSessions()).toEqual([{ id: 'a' }, { id: 'b' }])
+ })
+
+ it('accepts the older gateway shape {items:[...]}', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ items: [{ id: 'a' }] }))
+ const { listSessions } = await importClaudeApi()
+ expect(await listSessions()).toEqual([{ id: 'a' }])
+ })
+
+ it.each([
+ ['empty object', {}],
+ ['null body', null],
+ ['null list key', { sessions: null, items: null, data: null }],
+ ['non-array list key', { data: 'not-an-array' }],
+ ])('returns [] for %s from the gateway', async (_label, body) => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(body))
+ const { listSessions } = await importClaudeApi()
+ const result = await listSessions()
+ expect(result).toEqual([])
+ expect(Array.isArray(result)).toBe(true)
+ })
+
+ it('getMessages accepts data/items/messages shapes and never returns undefined', async () => {
+ const { getMessages } = await importClaudeApi()
+
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ object: 'list', data: [{ id: 1, role: 'user' }] }),
+ )
+ expect(await getMessages('s1')).toEqual([{ id: 1, role: 'user' }])
+
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ messages: [{ id: 2, role: 'assistant' }] }),
+ )
+ expect(await getMessages('s1')).toEqual([{ id: 2, role: 'assistant' }])
+
+ fetchMock.mockResolvedValueOnce(jsonResponse({}))
+ expect(await getMessages('s1')).toEqual([])
+ })
+
+ it('searchSessions always returns a results array', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse({ query: 'q', count: 0 }))
+ const { searchSessions } = await importClaudeApi()
+ const resp = await searchSessions('q')
+ expect(resp.results).toEqual([])
+ })
+})
diff --git a/src/server/claude-api.ts b/src/server/claude-api.ts
index 70fd35d30..ce15ee55d 100644
--- a/src/server/claude-api.ts
+++ b/src/server/claude-api.ts
@@ -12,9 +12,11 @@ import {
dashboardFetch,
ensureGatewayProbed,
getCapabilities,
+ markDashboardUnauthenticated,
probeGateway,
} from './gateway-capabilities'
import {
+ DashboardAuthError,
createSession as createDashboardSession,
deleteSession as deleteDashboardSession,
forkSession as forkDashboardSession,
@@ -125,33 +127,89 @@ export async function checkHealth(): Promise<{ status: string }> {
// ── Sessions ─────────────────────────────────────────────────────
+/**
+ * Route to the dashboard only when its auth-gated APIs are actually usable.
+ * `available` alone means "process reachable" (public /api/status) — on
+ * remote/Tailscale deployments the dashboard is typically reachable but
+ * cookie-gated, and dashboard calls would 401 (no_cookie). See #261-family
+ * Sessions-tab failures.
+ */
+function dashboardSessionsUsable(): boolean {
+ const d = getCapabilities().dashboard
+ return d.available && d.authenticated
+}
+
+/**
+ * Try the dashboard variant of a session call; on auth rejection, degrade
+ * the capability and fall back to the gateway variant. DashboardAuthError
+ * never reaches callers — on cookie-gated deployments the gateway is the
+ * source of truth for sessions. Non-auth errors are rethrown so real
+ * dashboard outages stay visible.
+ */
+async function sessionCall(
+ viaDashboard: () => Promise,
+ viaGateway: () => Promise,
+): Promise {
+ if (dashboardSessionsUsable()) {
+ try {
+ return await viaDashboard()
+ } catch (err) {
+ if (!(err instanceof DashboardAuthError)) throw err
+ markDashboardUnauthenticated()
+ }
+ }
+ return viaGateway()
+}
+
+/**
+ * Return the first array found under the given keys, else []. Callers
+ * .map/.slice/.length over these results — undefined must never escape.
+ */
+function asArray(
+ resp: unknown,
+ keys: ReadonlyArray,
+): Array {
+ const r = resp as Record | null | undefined
+ for (const k of keys) {
+ const v = r?.[k]
+ if (Array.isArray(v)) return v as Array
+ }
+ return []
+}
+
export async function listSessions(
limit = 50,
offset = 0,
): Promise> {
- if (getCapabilities().dashboard.available) {
- const resp = await listDashboardSessions(limit, offset)
- return resp.sessions as Array
- }
- const resp = await claudeGet<{
- items?: Array
- data?: Array
- total?: number
- }>(`/api/sessions?limit=${limit}&offset=${offset}`)
- // The gateway (OpenAI-compat) returns { object: 'list', data: [...] }, while the
- // dashboard / older gateway shape uses { items: [...] }. Accept either, and never
- // return undefined (callers .map over this).
- return resp.items ?? resp.data ?? []
+ return sessionCall(
+ async () =>
+ asArray(await listDashboardSessions(limit, offset), [
+ 'sessions',
+ 'items',
+ 'data',
+ ]),
+ async () =>
+ asArray(
+ await claudeGet(`/api/sessions?limit=${limit}&offset=${offset}`),
+ // Gateway (OpenAI-compat) returns { object: 'list', data: [...] };
+ // dashboard / older gateway shape uses { items: [...] }.
+ ['items', 'data', 'sessions'],
+ ),
+ )
}
export async function getSession(sessionId: string): Promise {
- if (getCapabilities().dashboard.available) {
- return getDashboardSession(sessionId) as Promise
- }
- const resp = await claudeGet<{ session: ClaudeSession }>(
- `/api/sessions/${sessionId}`,
+ return sessionCall(
+ async () => (await getDashboardSession(sessionId)) as ClaudeSession,
+ async () => {
+ const resp = await claudeGet<{ session?: ClaudeSession }>(
+ `/api/sessions/${sessionId}`,
+ )
+ // Gateway wraps in { session: {...} }; be liberal if a backend returns
+ // the bare session object.
+ return (resp.session ?? (resp as unknown)) as ClaudeSession
+ },
)
- return resp.session
}
export async function createSession(opts?: {
@@ -159,81 +217,98 @@ export async function createSession(opts?: {
title?: string
model?: string
}): Promise {
- if (getCapabilities().dashboard.available) {
- const resp = await createDashboardSession(opts || {})
- return resp.session as ClaudeSession
- }
- const resp = await claudePost<{ session: ClaudeSession }>(
- '/api/sessions',
- opts || {},
+ return sessionCall(
+ async () =>
+ (await createDashboardSession(opts || {})).session as ClaudeSession,
+ async () => {
+ const resp = await claudePost<{ session: ClaudeSession }>(
+ '/api/sessions',
+ opts || {},
+ )
+ return resp.session
+ },
)
- return resp.session
}
export async function updateSession(
sessionId: string,
updates: { title?: string },
): Promise {
- if (getCapabilities().dashboard.available) {
- const resp = await updateDashboardSession(sessionId, updates)
- return resp.session as ClaudeSession
- }
- const resp = await claudePatch<{ session: ClaudeSession }>(
- `/api/sessions/${sessionId}`,
- updates,
+ return sessionCall(
+ async () =>
+ (await updateDashboardSession(sessionId, updates))
+ .session as ClaudeSession,
+ async () => {
+ const resp = await claudePatch<{ session: ClaudeSession }>(
+ `/api/sessions/${sessionId}`,
+ updates,
+ )
+ return resp.session
+ },
)
- return resp.session
}
export async function deleteSession(sessionId: string): Promise {
- if (getCapabilities().dashboard.available) {
- await deleteDashboardSession(sessionId)
- return
- }
- return claudeDeleteReq(`/api/sessions/${sessionId}`)
+ return sessionCall(
+ async () => {
+ await deleteDashboardSession(sessionId)
+ },
+ async () => claudeDeleteReq(`/api/sessions/${sessionId}`),
+ )
}
export async function getMessages(
sessionId: string,
): Promise> {
- if (getCapabilities().dashboard.available) {
- const resp = await getDashboardSessionMessages(sessionId)
- return resp.messages as Array
- }
- const resp = await claudeGet<{
- items?: Array
- data?: Array
- messages?: Array
- total?: number
- }>(`/api/sessions/${sessionId}/messages`)
- // Gateway (OpenAI-compat) returns { object: 'list', data: [...] }; dashboard / older
- // shape uses { items: [...] }; some message endpoints use { messages: [...] }.
- // Accept any, and never return undefined (callers read .length / .map / .slice).
- return resp.items ?? resp.data ?? resp.messages ?? []
+ return sessionCall(
+ async () =>
+ asArray(await getDashboardSessionMessages(sessionId), [
+ 'messages',
+ 'items',
+ 'data',
+ ]),
+ async () =>
+ asArray(
+ await claudeGet(`/api/sessions/${sessionId}/messages`),
+ // Gateway (OpenAI-compat) returns { object: 'list', data: [...] };
+ // dashboard / older shape uses { items: [...] }; some message
+ // endpoints use { messages: [...] }.
+ ['items', 'data', 'messages'],
+ ),
+ )
}
export async function searchSessions(
query: string,
limit = 20,
): Promise<{ query?: string; count?: number; results: Array }> {
- if (getCapabilities().dashboard.available) {
- return searchDashboardSessions(query)
- }
- return claudeGet(
- `/api/sessions/search?q=${encodeURIComponent(query)}&limit=${limit}`,
+ const normalize = (resp: {
+ query?: string
+ count?: number
+ results?: Array
+ }) => ({ ...resp, results: resp.results ?? [] })
+ return sessionCall(
+ async () => normalize(await searchDashboardSessions(query)),
+ async () =>
+ normalize(
+ await claudeGet(
+ `/api/sessions/search?q=${encodeURIComponent(query)}&limit=${limit}`,
+ ),
+ ),
)
}
export async function forkSession(
sessionId: string,
): Promise<{ session: ClaudeSession; forked_from: string }> {
- if (getCapabilities().dashboard.available) {
- return forkDashboardSession(sessionId) as Promise<{
- session: ClaudeSession
- forked_from: string
- }>
- }
- return claudePost(`/api/sessions/${sessionId}/fork`)
+ return sessionCall(
+ async () =>
+ (await forkDashboardSession(sessionId)) as {
+ session: ClaudeSession
+ forked_from: string
+ },
+ async () => claudePost(`/api/sessions/${sessionId}/fork`),
+ )
}
// ── Conversion helpers (Claude → Chat format) ─────────────────
diff --git a/src/server/claude-dashboard-api.ts b/src/server/claude-dashboard-api.ts
index 7b4f0ee06..e9280063f 100644
--- a/src/server/claude-dashboard-api.ts
+++ b/src/server/claude-dashboard-api.ts
@@ -105,10 +105,40 @@ export type DashboardStatus = {
[key: string]: unknown
}
+/**
+ * Thrown when the dashboard rejects a request for auth reasons (401/403,
+ * e.g. `{"error":"unauthenticated","reason":"no_cookie"}` from a
+ * cookie-gated dashboard). Lets callers distinguish "auth-gated" from
+ * "broken" and fall back to the gateway instead of surfacing the 401.
+ */
+export class DashboardAuthError extends Error {
+ readonly status: number
+ readonly reason?: string
+
+ constructor(path: string, status: number, reason?: string) {
+ super(
+ `Hermes Agent dashboard ${path}: ${status}${reason ? ` (${reason})` : ''}`,
+ )
+ this.name = 'DashboardAuthError'
+ this.status = status
+ this.reason = reason
+ }
+}
+
async function dashboardJson(path: string, init?: RequestInit): Promise {
const res = await dashboardFetch(path, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
+ if (res.status === 401 || res.status === 403) {
+ let reason: string | undefined
+ try {
+ const body = JSON.parse(text) as { reason?: string; error?: string }
+ reason = body.reason || body.error
+ } catch {
+ // non-JSON auth error body — the status code alone is enough
+ }
+ throw new DashboardAuthError(path, res.status, reason)
+ }
throw new Error(`Hermes Agent dashboard ${path}: ${res.status} ${text}`)
}
if (res.status === 204) return undefined as T
diff --git a/src/server/gateway-capabilities.ts b/src/server/gateway-capabilities.ts
index 5c860d4e7..319f88ea0 100644
--- a/src/server/gateway-capabilities.ts
+++ b/src/server/gateway-capabilities.ts
@@ -203,7 +203,21 @@ export type EnhancedCapabilities = {
export type DashboardCapabilities = {
dashboard: {
+ /**
+ * The dashboard process is reachable (`/api/status` returned 200 + a
+ * version). Display/mode semantics only — reachability does NOT imply
+ * the auth-gated APIs will accept our requests.
+ */
available: boolean
+ /**
+ * The dashboard's auth-gated APIs actually accepted our credentials,
+ * probed against `GET /api/sessions?limit=1` through dashboardFetch (the
+ * same token path runtime calls use). Reachable-but-unauthenticated is
+ * the NORMAL state for remote/Tailscale deployments where the dashboard
+ * sits behind interactive cookie login (401 no_cookie). Server-side
+ * routing must check this flag, not `available`.
+ */
+ authenticated: boolean
url: string
}
}
@@ -247,6 +261,7 @@ let capabilities: GatewayCapabilities = {
kanban: false,
dashboard: {
available: false,
+ authenticated: false,
url: CLAUDE_DASHBOARD_URL,
},
probed: false,
@@ -257,10 +272,34 @@ let lastProbeAt = 0
let lastLoggedSummary = ''
let dashboardTokenPromise: Promise | null = null
let dashboardTokenCache = ''
+let dashboardCookie = ''
+let dashboardLoginPromise: Promise | null = null
+// After a failed login, skip further attempts for this window so bad/rotated
+// credentials can't hammer the rate-limited /auth/password-login endpoint into
+// an account lockout. Cleared on the next success.
+let dashboardLoginCooldownUntil = 0
+const DASHBOARD_LOGIN_COOLDOWN_MS = 30_000
/** Optional bearer token for authenticated gateway endpoints. */
export const BEARER_TOKEN = process.env.HERMES_API_TOKEN || process.env.CLAUDE_API_TOKEN || ''
+/**
+ * Dashboard login credentials for the built-in `basic` (username+password)
+ * auth provider. When set, the workspace authenticates to the dashboard the
+ * supported way — POST /auth/password-login → session cookie — instead of the
+ * removed inline-token scrape. Leave unset to preserve the legacy
+ * token/loopback behavior.
+ *
+ * These are the DASHBOARD's credentials, which are frequently DIFFERENT from
+ * the workspace's own login password. We deliberately do NOT fall back to
+ * HERMES_PASSWORD: that is commonly the workspace's separate password, and
+ * sending it to the dashboard would fail auth (and burn rate-limit budget).
+ * Set HERMES_DASHBOARD_PASSWORD explicitly to enable dashboard cookie auth.
+ */
+const DASHBOARD_USERNAME =
+ process.env.HERMES_DASHBOARD_USERNAME || process.env.HERMES_USERNAME || ''
+const DASHBOARD_PASSWORD = process.env.HERMES_DASHBOARD_PASSWORD || ''
+
/**
* Dashboard API auth uses the ephemeral session token injected into the
* dashboard root HTML at startup. Do not reuse gateway bearer tokens here and
@@ -338,6 +377,91 @@ export async function dashboardAuthHeaders(options?: {
return token ? { Authorization: `Bearer ${token}` } : {}
}
+/** True when username+password dashboard login is configured. */
+export function dashboardPasswordAuthConfigured(): boolean {
+ return !!DASHBOARD_PASSWORD
+}
+
+/**
+ * Collect the `name=value` pairs from a login response's Set-Cookie header(s)
+ * into a single Cookie header string. Uses undici's getSetCookie() when
+ * available (multiple cookies), falling back to the combined header.
+ */
+function collectCookies(res: Response): string {
+ const h = res.headers as unknown as { getSetCookie?: () => Array }
+ const raw =
+ typeof h.getSetCookie === 'function'
+ ? h.getSetCookie()
+ : res.headers.get('set-cookie')
+ ? [res.headers.get('set-cookie') as string]
+ : []
+ return raw
+ .map((c) => c.split(';')[0]?.trim())
+ .filter((c): c is string => !!c && c.includes('='))
+ .join('; ')
+}
+
+/**
+ * Authenticate to the dashboard via the built-in `basic` provider and cache
+ * the resulting session cookie. This is the supported replacement for the
+ * removed inline session-token scrape: the dashboard's own login form POSTs
+ * exactly this shape to /auth/password-login. Returns '' (degrading callers to
+ * unauthenticated → gateway fallback) when no password is configured or login
+ * fails, and never throws.
+ */
+export async function dashboardLogin(force = false): Promise {
+ if (!DASHBOARD_PASSWORD) return ''
+ if (!force && dashboardCookie) return dashboardCookie
+ // Respect the failure cooldown even under `force`: a rejected login must not
+ // be retried on every request, or a wrong/rotated password would lock us out.
+ if (Date.now() < dashboardLoginCooldownUntil) return ''
+ if (dashboardLoginPromise) return dashboardLoginPromise
+
+ dashboardLoginPromise = (async () => {
+ try {
+ const res = await fetch(`${CLAUDE_DASHBOARD_URL}/auth/password-login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: 'basic',
+ username: DASHBOARD_USERNAME,
+ password: DASHBOARD_PASSWORD,
+ next: '',
+ }),
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ })
+ if (!res.ok) {
+ dashboardLoginCooldownUntil = Date.now() + DASHBOARD_LOGIN_COOLDOWN_MS
+ console.warn(
+ `[gateway] Dashboard password-login failed (${res.status}) — dashboard APIs treated as unauthenticated (backing off ${DASHBOARD_LOGIN_COOLDOWN_MS / 1000}s)`,
+ )
+ dashboardCookie = ''
+ return ''
+ }
+ dashboardLoginCooldownUntil = 0
+ dashboardCookie = collectCookies(res)
+ if (!dashboardCookie) {
+ console.warn(
+ '[gateway] Dashboard login succeeded but returned no session cookie',
+ )
+ }
+ return dashboardCookie
+ } catch (err) {
+ dashboardLoginCooldownUntil = Date.now() + DASHBOARD_LOGIN_COOLDOWN_MS
+ console.warn(
+ `[gateway] Dashboard login error: ${err instanceof Error ? err.message : err}`,
+ )
+ return ''
+ }
+ })()
+
+ try {
+ return await dashboardLoginPromise
+ } finally {
+ dashboardLoginPromise = null
+ }
+}
+
function withDashboardBase(path: string): string {
if (/^https?:\/\//i.test(path)) return path
return `${CLAUDE_DASHBOARD_URL}${path.startsWith('/') ? path : `/${path}`}`
@@ -349,7 +473,8 @@ export async function dashboardFetch(
): Promise {
const requestPath = withDashboardBase(path)
const method = (init.method || 'GET').toUpperCase()
- const doFetch = async (forceToken = false) => {
+ const useCookieAuth = dashboardPasswordAuthConfigured()
+ const doFetch = async (forceReauth = false) => {
const headers = new Headers(init.headers)
const isProtected =
requestPath.includes('/api/') &&
@@ -361,10 +486,20 @@ export async function dashboardFetch(
!requestPath.endsWith('/api/dashboard/plugins') &&
!requestPath.endsWith('/api/dashboard/plugins/rescan')
- if (isProtected && !headers.has('Authorization')) {
- const auth = await dashboardAuthHeaders({ force: forceToken })
- for (const [key, value] of Object.entries(auth)) {
- headers.set(key, value)
+ if (isProtected) {
+ if (useCookieAuth) {
+ // Preferred: authenticate with the dashboard's `basic` provider and
+ // reuse the session cookie (the supported path; inline tokens are gone).
+ if (!headers.has('Cookie')) {
+ const cookie = await dashboardLogin(forceReauth)
+ if (cookie) headers.set('Cookie', cookie)
+ }
+ } else if (!headers.has('Authorization')) {
+ // Legacy: loopback/unauthenticated dashboards or bearer-token setups.
+ const auth = await dashboardAuthHeaders({ force: forceReauth })
+ for (const [key, value] of Object.entries(auth)) {
+ headers.set(key, value)
+ }
}
}
@@ -377,7 +512,9 @@ export async function dashboardFetch(
let res = await doFetch(false)
if (res.status === 401) {
- dashboardTokenCache = ''
+ // Cookie/token expired or auth gate newly engaged — reauthenticate once.
+ if (useCookieAuth) dashboardCookie = ''
+ else dashboardTokenCache = ''
res = await doFetch(true)
}
return res
@@ -591,19 +728,61 @@ async function probeMcpConfigKey(): Promise {
}
}
-async function probeDashboard(): Promise<{ available: boolean; url: string }> {
+async function probeDashboard(): Promise<{
+ available: boolean
+ authenticated: boolean
+ url: string
+}> {
+ const unavailable = {
+ available: false,
+ authenticated: false,
+ url: CLAUDE_DASHBOARD_URL,
+ }
try {
const res = await fetch(`${CLAUDE_DASHBOARD_URL}/api/status`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
})
- if (!res.ok) return { available: false, url: CLAUDE_DASHBOARD_URL }
+ if (!res.ok) return unavailable
const body = (await res.json()) as { version?: string }
- if (!body.version) return { available: false, url: CLAUDE_DASHBOARD_URL }
+ if (!body.version) return unavailable
await fetchDashboardToken().catch(() => '')
- return { available: true, url: CLAUDE_DASHBOARD_URL }
+ // /api/status is public: it proves reachability, not usability. Probe a
+ // real auth-gated endpoint through dashboardFetch (the same token path
+ // runtime calls use) so cookie-gated dashboards — the normal state on
+ // remote/Tailscale deployments — are classified as
+ // reachable-but-unauthenticated instead of 401ing (no_cookie) at click
+ // time in the Sessions tab.
+ let authenticated = false
+ try {
+ const authRes = await dashboardFetch('/api/sessions?limit=1&offset=0', {
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
+ })
+ authenticated = authRes.ok
+ } catch {
+ authenticated = false
+ }
+ return { available: true, authenticated, url: CLAUDE_DASHBOARD_URL }
} catch {
- return { available: false, url: CLAUDE_DASHBOARD_URL }
+ return unavailable
+ }
+}
+
+/**
+ * Degrade the dashboard to reachable-but-unauthenticated at runtime, e.g.
+ * when a dashboard call 401s after a successful probe (ephemeral token
+ * expired, or the auth gate was enabled without a workspace restart).
+ * Routing helpers consult `dashboard.authenticated` on every call, so the
+ * effect is immediate; the flag is recomputed by the next full probe.
+ */
+export function markDashboardUnauthenticated(): void {
+ if (!capabilities.dashboard.authenticated) return
+ capabilities = {
+ ...capabilities,
+ dashboard: { ...capabilities.dashboard, authenticated: false },
}
+ console.warn(
+ '[gateway] dashboard APIs rejected credentials (401) — routing dashboard-backed calls to the gateway until the next probe',
+ )
}
/**
@@ -864,7 +1043,7 @@ export async function probeGateway(options?: {
// Phase 1.5 fallback: when native /api/mcp is missing but the dashboard
// exposes `config.mcp_servers` AND we are loopback-only, allow a config
// -backed CRUD path. Test/Discover/Logs remain disabled in this mode.
- const dashboardConfigAvailable = dashboard.available || legacyConfig
+ const dashboardConfigAvailable = dashboard.authenticated || legacyConfig
const mcpFallback =
!mcp &&
dashboard.available &&
@@ -878,15 +1057,19 @@ export async function probeGateway(options?: {
models,
streaming: chatCompletions,
probed: true,
- sessions: dashboard.available || legacySessions,
+ // Dashboard-backed composites require *authenticated*, not merely
+ // reachable: every dashboard call goes through the cookie/token gate,
+ // so a reachable-but-gated dashboard cannot back these APIs. The
+ // legacy gateway probes keep them available on gateway-only setups.
+ sessions: dashboard.authenticated || legacySessions,
enhancedChat,
- skills: dashboard.available || legacySkills,
+ skills: dashboard.authenticated || legacySkills,
// Memory is always available: workspace reads $HERMES_HOME/MEMORY.md +
// memory/*.md + memories/*.md directly from the local filesystem.
// No remote gateway endpoint is required.
memory: true,
- config: dashboard.available || legacyConfig,
- jobs: dashboard.available || legacyJobs,
+ config: dashboard.authenticated || legacyConfig,
+ jobs: dashboard.authenticated || legacyJobs,
mcp,
mcpFallback,
conductor,