diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index 48a3c8f..7a17612 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-BBEijEwg.js"); +require("./main-DpM79zJd.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/dist-electron/preload/preload.js b/dist-electron/preload/preload.js index 817d1d3..2008e6b 100644 --- a/dist-electron/preload/preload.js +++ b/dist-electron/preload/preload.js @@ -2,6 +2,7 @@ const electron = require("electron"); var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => { IPC_EVENTS2["HOST_API_FETCH"] = "hostapi:fetch"; + IPC_EVENTS2["HOST_API_TOKEN"] = "hostapi:token"; IPC_EVENTS2["EXTERNAL_OPEN"] = "external-open"; IPC_EVENTS2["APP_SET_FRAMELESS"] = "app:set-frameless"; IPC_EVENTS2["APP_LOAD_PAGE"] = "app:load-page"; diff --git a/electron/api/server.ts b/electron/api/server.ts index 211d37d..34f12d3 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -11,6 +11,7 @@ import { } from './route-utils'; const DEFAULT_HOST_API_PORT = 13210; +const HOST_API_UNAUTHORIZED_CODE = 'HOST_API_UNAUTHORIZED'; type StartHostApiServerOptions = { ctx: HostApiContext; @@ -50,21 +51,19 @@ export function startHostApiServer(options: StartHostApiServerOptions): Server { return; } - const bearerHeader = req.headers.authorization || ''; - const bearerToken = bearerHeader.startsWith('Bearer ') - ? bearerHeader.slice('Bearer '.length) - : ''; - const token = ( - req.headers['x-host-api-token'] - || requestUrl.searchParams.get('token') - || bearerToken - ); + const headerToken = req.headers['x-host-api-token']; + const token = typeof headerToken === 'string' + ? headerToken + : Array.isArray(headerToken) + ? headerToken[0] + : (requestUrl.searchParams.get('token') || ''); if (token !== hostApiToken) { sendJsonResponse(res, 401, { success: false, ok: false, - error: 'Unauthorized', + code: HOST_API_UNAUTHORIZED_CODE, + error: 'Host API authentication failed', }); return; } diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index ae7949e..62fe672 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -18,8 +18,6 @@ import { CONFIG_KEYS } from '@runtime/lib/constants'; import { normalizeAgentSessionKey } from '@runtime/lib/models'; import type { ContentBlock, RawMessage } from '@runtime/shared/chat-model'; import type { GatewayEvent, GatewayRpcParams, RuntimeRefreshTopic } from './types'; -import * as providerHandlers from './handlers/provider'; -import * as skillHandlers from './handlers/skills'; import { createInitialGatewayDiagnostics, type GatewayDiagnosticsSnapshot, @@ -64,6 +62,7 @@ import { } from './reload-policy'; import { GatewayStateController, type GatewayRuntimeStatus } from './state'; import { connectGatewaySocket, waitForGatewayReady } from './ws-client'; +import { dispatchGatewayRpcMethod } from './rpc-dispatch'; type RuntimeChangeBroadcast = { topics: RuntimeRefreshTopic[]; @@ -1785,88 +1784,16 @@ export class GatewayManager extends EventEmitter { await this.init(); } + const localDispatch = dispatchGatewayRpcMethod( + method, + params, + (event) => this.broadcast(event), + ); + if (localDispatch.handled) { + return localDispatch.result; + } + switch (method) { - case 'chat.send': { - const request = params as GatewayRpcParams['chat.send']; - const sessionKey = normalizeAgentSessionKey(request.sessionKey); - const messageText = extractTextFromRawMessage(request.message); - const response = await this.rpcGateway('chat.send', { - sessionKey, - message: messageText, - deliver: false, - idempotencyKey: request.message.id || randomUUID(), - }, { timeoutMs: 30_000 }); - - const runId = ( - isRecord(response) && - typeof response.runId === 'string' && - response.runId.trim() - ) - ? response.runId - : ''; - - if (!runId) { - throw new Error('OpenClaw Gateway chat.send did not return a runId'); - } - - return { runId }; - } - case 'chat.history': { - const request = params as GatewayRpcParams['chat.history']; - const response = await this.rpcGateway('chat.history', { - sessionKey: normalizeAgentSessionKey(request.sessionKey), - limit: request.limit ?? 50, - }, { timeoutMs: 15_000 }); - - if (!isRecord(response) || !Array.isArray(response.messages)) { - return []; - } - - return response.messages - .map((message) => normalizeGatewayRawMessage(message)) - .filter((message): message is RawMessage => message !== null); - } - case 'chat.abort': { - const request = params as GatewayRpcParams['chat.abort']; - await this.rpcGateway('chat.abort', { - sessionKey: normalizeAgentSessionKey(request.sessionKey), - }, { timeoutMs: 10_000 }); - return; - } - case 'session.list': { - const response = await this.rpcGateway('sessions.list', {}, { timeoutMs: 10_000 }); - if (!isRecord(response) || !Array.isArray(response.sessions)) { - return []; - } - - return response.sessions - .map((session) => ( - isRecord(session) && typeof session.key === 'string' - ? session.key - : null - )) - .filter((sessionKey): sessionKey is string => Boolean(sessionKey)); - } - case 'session.delete': { - const request = params as GatewayRpcParams['session.delete']; - if (normalizeAgentSessionKey(request.sessionKey).endsWith(':main')) { - return { success: false }; - } - - await this.rpcGateway('sessions.delete', { - key: normalizeAgentSessionKey(request.sessionKey), - deleteTranscript: true, - }, { timeoutMs: 15_000 }); - return { success: true }; - } - case 'provider.list': - return providerHandlers.handleProviderList(); - case 'provider.getDefault': - return providerHandlers.handleProviderGetDefault(); - case 'skills.status': - return skillHandlers.handleSkillsStatus(); - case 'skills.update': - return skillHandlers.handleSkillsUpdate(params); default: throw new Error(`Unknown gateway RPC method: ${method}`); } diff --git a/electron/gateway/rpc-dispatch.ts b/electron/gateway/rpc-dispatch.ts new file mode 100644 index 0000000..bd65805 --- /dev/null +++ b/electron/gateway/rpc-dispatch.ts @@ -0,0 +1,80 @@ +import { normalizeAgentSessionKey } from '@runtime/lib/models'; +import type { GatewayEvent, GatewayRpcParams } from './types'; +import * as chatHandlers from './handlers/chat'; +import * as providerHandlers from './handlers/provider'; +import * as skillHandlers from './handlers/skills'; + +type GatewayBroadcast = (event: GatewayEvent) => void; + +export function dispatchGatewayRpcMethod( + method: string, + params: unknown, + broadcast: GatewayBroadcast, +): { handled: boolean; result?: unknown } { + switch (method) { + case 'chat.send': + return { + handled: true, + result: chatHandlers.handleChatSend( + params as GatewayRpcParams['chat.send'], + broadcast, + ), + }; + case 'chat.history': + return { + handled: true, + result: chatHandlers.handleChatHistory( + params as GatewayRpcParams['chat.history'], + ), + }; + case 'chat.abort': + return { + handled: true, + result: chatHandlers.handleChatAbort( + params as GatewayRpcParams['chat.abort'], + broadcast, + ), + }; + case 'session.list': + return { + handled: true, + result: chatHandlers.handleSessionList(), + }; + case 'session.delete': { + const request = params as GatewayRpcParams['session.delete']; + if (normalizeAgentSessionKey(request.sessionKey).endsWith(':main')) { + return { + handled: true, + result: { success: false }, + }; + } + + return { + handled: true, + result: chatHandlers.handleSessionDelete(request), + }; + } + case 'provider.list': + return { + handled: true, + result: providerHandlers.handleProviderList(), + }; + case 'provider.getDefault': + return { + handled: true, + result: providerHandlers.handleProviderGetDefault(), + }; + case 'skills.status': + return { + handled: true, + result: skillHandlers.handleSkillsStatus(), + }; + case 'skills.update': + return { + handled: true, + result: skillHandlers.handleSkillsUpdate(params), + }; + default: + return { handled: false }; + } +} diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index ec908a4..21143fa 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -1,11 +1,12 @@ import { IPC_EVENTS } from './constants'; import type { HostApiResult } from '../types/runtime'; -import { logout, readPersistedAuthToken } from '../router/auth-session'; +import { readPersistedAuthToken } from '../router/auth-session'; type RequestInitLike = Pick; const HOST_API_PORT = 13210; const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; +const HOST_API_UNAUTHORIZED_CODE = 'HOST_API_UNAUTHORIZED'; type LooseIpcBridge = { invoke(channel: string, ...args: any[]): Promise; @@ -14,6 +15,11 @@ type LooseIpcBridge = { let cachedHostApiToken: string | null = null; +type HostApiErrorDetails = { + code?: string; + message: string; +}; + function normalizeHeaders(headers?: HeadersInit): Headers { return new Headers(headers ?? {}); } @@ -41,9 +47,6 @@ function extractResult(response: unknown): T { if (response && typeof response === 'object') { const result = response as HostApiResult; if (result.success === false || result.ok === false) { - if (isUnauthorizedStatus(result.status) || isUnauthorizedMessage(result.error) || isUnauthorizedMessage(result.text)) { - handleUnauthorized(); - } throw new Error(result.error || result.text || 'Request failed'); } @@ -57,23 +60,12 @@ function extractResult(response: unknown): T { return response as T; } -function isUnauthorizedStatus(status?: number): boolean { - return status === 401; +function isHostApiUnauthorized(code?: string): boolean { + return code === HOST_API_UNAUTHORIZED_CODE; } -function isUnauthorizedMessage(message?: string): boolean { - if (!message) return false; - - return /\b401\b|unauthorized|unauthenticated|invalid token|token expired|鉴权失败|认证失败|未授权|未登录|登录失效|token已失效/i.test(message); -} - -function handleUnauthorized(): void { - const from = typeof window === 'undefined' ? undefined : window.location.hash.replace(/^#/, '') || undefined; - logout({ reason: 'unauthorized', from }); -} - -async function getHostApiToken(): Promise { - if (cachedHostApiToken) { +async function getHostApiToken(forceRefresh = false): Promise { + if (!forceRefresh && cachedHostApiToken) { return cachedHostApiToken; } @@ -81,11 +73,34 @@ async function getHostApiToken(): Promise { return cachedHostApiToken; } +async function parseHostApiError(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + + if (contentType.includes('application/json')) { + const payload = (await response.json().catch(() => ({}))) as HostApiResult & { message?: string }; + return { + code: typeof payload.code === 'string' ? payload.code : undefined, + message: + payload.error + || payload.text + || payload.message + || response.statusText + || `Request failed with ${response.status}`, + }; + } + + const text = await response.text(); + return { + message: text || response.statusText || `Request failed with ${response.status}`, + }; +} + async function fetchViaLocalHostApi( path: string, method: string, headers: Headers, body: BodyInit | null | undefined, + allowRetry = true, ): Promise { const hostApiToken = await getHostApiToken(); const localHeaders = new Headers(headers); @@ -103,14 +118,14 @@ async function fetchViaLocalHostApi( }); if (!response.ok) { - if (isUnauthorizedStatus(response.status)) { - handleUnauthorized(); + const errorDetails = await parseHostApiError(response); + + if (response.status === 401 && isHostApiUnauthorized(errorDetails.code) && allowRetry) { + cachedHostApiToken = null; + return fetchViaLocalHostApi(path, method, headers, body, false); } - const text = await response.text(); - if (!isUnauthorizedStatus(response.status) && isUnauthorizedMessage(text)) { - handleUnauthorized(); - } - throw new Error(text || response.statusText || `Request failed with ${response.status}`); + + throw new Error(errorDetails.message); } const contentType = response.headers.get('content-type') ?? ''; @@ -199,13 +214,7 @@ export async function hostApiFetch(path: string, init?: RequestInitLike): Pro }); if (!response.ok) { - if (isUnauthorizedStatus(response.status)) { - handleUnauthorized(); - } const text = await response.text(); - if (!isUnauthorizedStatus(response.status) && isUnauthorizedMessage(text)) { - handleUnauthorized(); - } throw new Error(text || response.statusText || `Request failed with ${response.status}`); } @@ -224,7 +233,7 @@ export async function hostApiFetch(path: string, init?: RequestInitLike): Pro } export async function createHostEventSource(path = '/api/events'): Promise { - const token = await getHostApiToken(); + const token = await getHostApiToken(true); const separator = path.includes('?') ? '&' : '?'; return new EventSource(`${HOST_API_BASE}${path}${separator}token=${encodeURIComponent(token)}`); } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index cb2974c..696d45a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -57,6 +57,7 @@ export interface HostApiResult { text?: string; error?: string; status?: number; + code?: string; } export type GatewayEvent = diff --git a/tests/app-router-host-api-auth-regression.test.tsx b/tests/app-router-host-api-auth-regression.test.tsx new file mode 100644 index 0000000..90598ed --- /dev/null +++ b/tests/app-router-host-api-auth-regression.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { HashRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../src/i18n'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock('../src/pages/Login', async () => { + const ReactModule = await import('react'); + return { + default: function LoginPageMock() { + return ReactModule.createElement('div', null, 'login-page'); + }, + }; +}); + +vi.mock('../src/pages/Setting', async () => { + const ReactModule = await import('react'); + + return { + default: function SettingPageMock() { + ReactModule.useEffect(() => { + void import('../src/lib/host-api').then(({ hostApiFetch }) => { + void hostApiFetch('/api/gateway/status').catch(() => {}); + }); + }, []); + + return ReactModule.createElement('div', null, 'protected-setting-page'); + }, + }; +}); + +import { AppRouter } from '../src/router'; + +describe('AppRouter host api auth regression', () => { + function renderProtectedRoute() { + render( + + + , + ); + } + + beforeEach(() => { + vi.clearAllMocks(); + setLocale('en'); + window.sessionStorage.clear(); + window.localStorage.clear(); + document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + window.location.hash = '#/setting'; + window.sessionStorage.setItem('token', JSON.stringify('access-token')); + + (window as typeof window & { api?: unknown }).api = { + invoke: mocks.invoke, + platform: 'darwin', + }; + + mocks.invoke.mockImplementation(async (channel: string) => { + if (channel === 'hostapi:fetch') { + return { + success: false, + ok: false, + status: 401, + code: 'HOST_API_UNAUTHORIZED', + error: 'Host API authentication failed', + }; + } + + throw new Error(`Unexpected IPC channel: ${channel}`); + }); + }); + + it('keeps the user on a protected route when local host api auth fails', async () => { + renderProtectedRoute(); + + expect(await screen.findByText('protected-setting-page')).toBeTruthy(); + + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith('hostapi:fetch', expect.objectContaining({ + path: '/api/gateway/status', + })); + }); + + await waitFor(() => { + expect(screen.queryByText('login-page')).toBeNull(); + expect(screen.getByText('protected-setting-page')).toBeTruthy(); + expect(window.sessionStorage.getItem('token')).toBe(JSON.stringify('access-token')); + }); + }); + + it('keeps the user on a protected route when upstream api returns unauthorized', async () => { + mocks.invoke.mockResolvedValueOnce({ + success: false, + ok: false, + status: 401, + error: 'Unauthorized', + }); + + renderProtectedRoute(); + + expect(await screen.findByText('protected-setting-page')).toBeTruthy(); + + await waitFor(() => { + expect(mocks.invoke).toHaveBeenCalledWith('hostapi:fetch', expect.objectContaining({ + path: '/api/gateway/status', + })); + }); + + await waitFor(() => { + expect(screen.queryByText('login-page')).toBeNull(); + expect(screen.getByText('protected-setting-page')).toBeTruthy(); + expect(window.sessionStorage.getItem('token')).toBe(JSON.stringify('access-token')); + }); + }); +}); diff --git a/tests/gateway-rpc-dispatch.test.ts b/tests/gateway-rpc-dispatch.test.ts new file mode 100644 index 0000000..abfcad5 --- /dev/null +++ b/tests/gateway-rpc-dispatch.test.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + handleChatSend: vi.fn(), + handleChatHistory: vi.fn(), + handleChatAbort: vi.fn(), + handleSessionList: vi.fn(), + handleSessionDelete: vi.fn(), + handleProviderList: vi.fn(), + handleProviderGetDefault: vi.fn(), + handleSkillsStatus: vi.fn(), + handleSkillsUpdate: vi.fn(), +})); + +vi.mock('../electron/gateway/handlers/chat', () => ({ + handleChatSend: mocks.handleChatSend, + handleChatHistory: mocks.handleChatHistory, + handleChatAbort: mocks.handleChatAbort, + handleSessionList: mocks.handleSessionList, + handleSessionDelete: mocks.handleSessionDelete, +})); + +vi.mock('../electron/gateway/handlers/provider', () => ({ + handleProviderList: mocks.handleProviderList, + handleProviderGetDefault: mocks.handleProviderGetDefault, +})); + +vi.mock('../electron/gateway/handlers/skills', () => ({ + handleSkillsStatus: mocks.handleSkillsStatus, + handleSkillsUpdate: mocks.handleSkillsUpdate, +})); + +describe('dispatchGatewayRpcMethod', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('routes chat history to the local chat handler', async () => { + const messages = [{ role: 'user', content: 'hello' }]; + mocks.handleChatHistory.mockReturnValue(messages); + + const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + + const result = dispatchGatewayRpcMethod( + 'chat.history', + { sessionKey: 'agent:test:main', limit: 20 }, + vi.fn(), + ); + + expect(result).toEqual({ handled: true, result: messages }); + expect(mocks.handleChatHistory).toHaveBeenCalledWith({ + sessionKey: 'agent:test:main', + limit: 20, + }); + }); + + it('routes chat send locally and forwards the broadcast callback', async () => { + mocks.handleChatSend.mockReturnValue({ runId: 'run-1' }); + + const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + const broadcast = vi.fn(); + + const result = dispatchGatewayRpcMethod( + 'chat.send', + { + sessionKey: 'agent:test:main', + message: { role: 'user', content: 'hello' }, + }, + broadcast, + ); + + expect(result).toEqual({ handled: true, result: { runId: 'run-1' } }); + expect(mocks.handleChatSend).toHaveBeenCalledTimes(1); + expect(mocks.handleChatSend.mock.calls[0]?.[1]).toBe(broadcast); + }); + + it('prevents deleting the main session', async () => { + const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + + const result = dispatchGatewayRpcMethod( + 'session.delete', + { sessionKey: 'agent:test:main' }, + vi.fn(), + ); + + expect(result).toEqual({ handled: true, result: { success: false } }); + expect(mocks.handleSessionDelete).not.toHaveBeenCalled(); + }); + + it('routes non-main session deletion and session listing locally', async () => { + mocks.handleSessionDelete.mockReturnValue({ success: true }); + mocks.handleSessionList.mockReturnValue(['agent:test:main', 'agent:test:secondary']); + + const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + + expect( + dispatchGatewayRpcMethod('session.list', {}, vi.fn()), + ).toEqual({ + handled: true, + result: ['agent:test:main', 'agent:test:secondary'], + }); + expect( + dispatchGatewayRpcMethod( + 'session.delete', + { sessionKey: 'agent:test:secondary' }, + vi.fn(), + ), + ).toEqual({ + handled: true, + result: { success: true }, + }); + expect(mocks.handleSessionDelete).toHaveBeenCalledWith({ + sessionKey: 'agent:test:secondary', + }); + }); + + it('routes provider and skills methods locally and leaves unknown methods unhandled', async () => { + mocks.handleProviderGetDefault.mockReturnValue({ accountId: 'provider-1' }); + mocks.handleSkillsStatus.mockReturnValue({ skills: [] }); + mocks.handleSkillsUpdate.mockReturnValue({ success: true }); + + const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch'); + + expect( + dispatchGatewayRpcMethod('provider.getDefault', {}, vi.fn()), + ).toEqual({ + handled: true, + result: { accountId: 'provider-1' }, + }); + expect( + dispatchGatewayRpcMethod('skills.status', {}, vi.fn()), + ).toEqual({ + handled: true, + result: { skills: [] }, + }); + expect( + dispatchGatewayRpcMethod('skills.update', { skillKey: 'demo' }, vi.fn()), + ).toEqual({ + handled: true, + result: { success: true }, + }); + expect( + dispatchGatewayRpcMethod('gateway.ping', {}, vi.fn()), + ).toEqual({ + handled: false, + }); + }); +}); diff --git a/tests/host-api-auth.test.ts b/tests/host-api-auth.test.ts new file mode 100644 index 0000000..9b7f0bb --- /dev/null +++ b/tests/host-api-auth.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + logout: vi.fn(), + readPersistedAuthToken: vi.fn(), + invoke: vi.fn(), +})); + +vi.mock('../src/router/auth-session', () => ({ + logout: mocks.logout, + readPersistedAuthToken: mocks.readPersistedAuthToken, +})); + +const HOST_API_UNAUTHORIZED_CODE = 'HOST_API_UNAUTHORIZED'; + +describe('hostApiFetch auth handling', () => { + beforeEach(() => { + vi.resetModules(); + mocks.logout.mockReset(); + mocks.readPersistedAuthToken.mockReset(); + mocks.invoke.mockReset(); + mocks.readPersistedAuthToken.mockReturnValue('access-token'); + (window as typeof window & { api?: unknown }).api = { + invoke: mocks.invoke, + }; + }); + + afterEach(() => { + delete (window as typeof window & { api?: unknown }).api; + }); + + it('does not log out when local Host API authentication fails', async () => { + mocks.invoke.mockResolvedValue({ + success: false, + ok: false, + status: 401, + code: HOST_API_UNAUTHORIZED_CODE, + error: 'Host API authentication failed', + }); + + const { hostApiFetch } = await import('../src/lib/host-api'); + + await expect(hostApiFetch('/api/gateway/status')).rejects.toThrow('Host API authentication failed'); + expect(mocks.logout).not.toHaveBeenCalled(); + }); + + it('keeps auth state when upstream business API returns unauthorized', async () => { + mocks.invoke.mockResolvedValue({ + success: false, + ok: false, + status: 401, + error: 'Unauthorized', + }); + + const { hostApiFetch } = await import('../src/lib/host-api'); + + await expect(hostApiFetch('/api/providers')).rejects.toThrow('Unauthorized'); + expect(mocks.logout).not.toHaveBeenCalled(); + }); +});