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,