| import { SettingsClient } from "@openhands/typescript-client/clients"; |
| import type { |
| MCPConfigPatch, |
| MCPServer, |
| MCPServerPatch, |
| } from "@openhands/typescript-client"; |
| import { DEFAULT_SETTINGS } from "#/services/settings"; |
| import { Settings, SettingsSchema, SettingsValue } from "#/types/settings"; |
| import { stringRecord } from "#/utils/mcp-config"; |
| import type { SkillEnablement } from "#/utils/skill-enablement"; |
| import { getActiveBackend } from "../backend-registry/active-store"; |
| import { |
| fetchCloudConversationSettingsSchema, |
| fetchCloudSettings, |
| fetchCloudSettingsSchema, |
| saveCloudSettings, |
| } from "../cloud/settings-service.api"; |
| import { getAgentServerClientOptions } from "../agent-server-client-options"; |
|
|
| |
| |
| |
| |
| |
| |
| export const APP_PREFERENCE_FIELDS = [ |
| "language", |
| "user_consents_to_analytics", |
| "enable_sound_notifications", |
| "git_user_name", |
| "git_user_email", |
| "title_llm_profile", |
| "disabled_skills", |
| "enabled_skills", |
| ] as const; |
|
|
| export type AppPreferenceField = (typeof APP_PREFERENCE_FIELDS)[number]; |
|
|
| export type AppPreferences = Partial<Pick<Settings, AppPreferenceField>>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export interface MiscSettings { |
| app_preferences?: AppPreferences; |
| } |
|
|
| |
| |
| |
| |
| export interface SettingsApiResponse { |
| agent_settings: Record<string, SettingsValue>; |
| conversation_settings: Record<string, SettingsValue>; |
| llm_api_key_is_set: boolean; |
| |
| |
| |
| |
| |
| misc_settings?: MiscSettings; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export interface SettingsUpdateRequest { |
| agent_settings_diff?: Record<string, SettingsValue>; |
| conversation_settings_diff?: Record<string, SettingsValue>; |
| misc_settings_diff?: MiscSettings; |
| |
| |
| |
| [key: string]: unknown; |
| } |
|
|
| const APP_PREFERENCE_FIELD_SET: ReadonlySet<string> = new Set( |
| APP_PREFERENCE_FIELDS, |
| ); |
|
|
| const isAppPreferenceField = (key: string): key is AppPreferenceField => |
| APP_PREFERENCE_FIELD_SET.has(key); |
|
|
| |
| |
| |
| |
| |
| |
| const extractAppPreferences = ( |
| input: Record<string, unknown>, |
| ): { extracted: AppPreferences; rest: Record<string, unknown> } => { |
| const extracted: AppPreferences = {}; |
| const rest: Record<string, unknown> = {}; |
| for (const [key, value] of Object.entries(input)) { |
| if (isAppPreferenceField(key)) { |
| (extracted as Record<string, unknown>)[key] = value; |
| } else { |
| rest[key] = value; |
| } |
| } |
| return { extracted, rest }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export type ExposeSecretsMode = "encrypted" | "plaintext" | undefined; |
|
|
| const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T; |
|
|
| const mergeRecords = ( |
| base: Record<string, SettingsValue> | null | undefined, |
| next: Record<string, SettingsValue> | null | undefined, |
| ) => ({ ...(base ?? {}), ...(next ?? {}) }); |
|
|
| |
| |
| |
| async function withRetry<T>( |
| fn: () => Promise<T>, |
| maxRetries: number = 3, |
| baseDelayMs: number = 500, |
| ): Promise<T> { |
| for (let attempt = 0; attempt < maxRetries; attempt += 1) { |
| try { |
| return await fn(); |
| } catch (error) { |
| if (attempt >= maxRetries - 1) { |
| throw error; |
| } |
|
|
| const delay = baseDelayMs * 2 ** attempt; |
|
|
| await new Promise<void>((resolve) => { |
| setTimeout(resolve, delay); |
| }); |
| } |
| } |
|
|
| throw new Error("Retry attempts exhausted"); |
| } |
|
|
| |
| |
| |
| |
| let settingsCache: { |
| |
| redacted: SettingsApiResponse | null; |
| |
| encrypted: SettingsApiResponse | null; |
| |
| timestamp: number; |
| } = { |
| redacted: null, |
| encrypted: null, |
| timestamp: 0, |
| }; |
|
|
| const CACHE_TTL_MS = 5 * 60 * 1000; |
|
|
| const isCacheValid = () => Date.now() - settingsCache.timestamp < CACHE_TTL_MS; |
|
|
| const clearCache = () => { |
| settingsCache = { redacted: null, encrypted: null, timestamp: 0 }; |
| }; |
|
|
| const isRecord = (value: unknown): value is Record<string, unknown> => |
| !!value && typeof value === "object" && !Array.isArray(value); |
|
|
| const basicAuthHeader = (username: string, password: string): string => { |
| const token = btoa(`${username}:${password}`); |
| return `Basic ${token}`; |
| }; |
|
|
| const headersFromMcpAuth = ( |
| auth: Record<string, unknown>, |
| ): Record<string, string> | null => { |
| switch (auth.strategy) { |
| case "none": |
| return {}; |
| case "api_key": { |
| if (typeof auth.value !== "string" || !auth.value) return null; |
| const header = |
| typeof auth.header_name === "string" && auth.header_name |
| ? auth.header_name |
| : "Authorization"; |
| const value = |
| header === "Authorization" ? `Bearer ${auth.value}` : auth.value; |
| return { [header]: value }; |
| } |
| case "bearer": |
| if (typeof auth.value !== "string" || !auth.value) return null; |
| return { Authorization: `Bearer ${auth.value}` }; |
| case "basic": |
| if ( |
| typeof auth.username !== "string" || |
| typeof auth.password !== "string" |
| ) { |
| return null; |
| } |
| return { Authorization: basicAuthHeader(auth.username, auth.password) }; |
| case "header": |
| return stringRecord(auth.headers) ?? {}; |
| case "oauth2": { |
| const tokens = isRecord(auth.state) ? auth.state.tokens : undefined; |
| if (!isRecord(tokens) || typeof tokens.access_token !== "string") { |
| return null; |
| } |
| return { Authorization: `Bearer ${tokens.access_token}` }; |
| } |
| default: |
| return null; |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const cloudCompatibleMcpConfig = async (value: unknown): Promise<unknown> => { |
| if (!isRecord(value)) return value; |
|
|
| const hasWrapper = isRecord(value.mcpServers); |
| const serverMap: Record<string, unknown> = hasWrapper |
| ? (value.mcpServers as Record<string, unknown>) |
| : value; |
|
|
| const needsStoredCredential = |
| Object.values(serverMap).some( |
| (server) => |
| isRecord(server) && isRecord(server.auth) && server.auth !== null, |
| ) && getActiveBackend().backend.kind === "cloud"; |
|
|
| const storedHeadersByServer = new Map<string, Record<string, string>>(); |
| if (needsStoredCredential) { |
| try { |
| const stored = await fetchCloudSettings(); |
| const storedMcp = isRecord(stored.mcp_config) ? stored.mcp_config : {}; |
| for (const [name, server] of Object.entries(storedMcp)) { |
| if (isRecord(server)) { |
| const headers = stringRecord( |
| (server as Record<string, unknown>).headers, |
| ); |
| if (headers) storedHeadersByServer.set(name, headers); |
| } |
| } |
| } catch { |
| |
| |
| } |
| } |
|
|
| const converted = Object.fromEntries( |
| Object.entries(serverMap).map(([name, server]) => { |
| if (!isRecord(server)) return [name, server]; |
| if (server.auth === null) { |
| const nextServer: Record<string, unknown> = { |
| ...server, |
| headers: null, |
| }; |
| delete nextServer.auth; |
| return [name, nextServer]; |
| } |
| if (!isRecord(server.auth)) return [name, server]; |
|
|
| const authHeaders = headersFromMcpAuth(server.auth); |
| if (authHeaders === null) return [name, server]; |
|
|
| const nextServer = { ...server }; |
| const existingHeaders = stringRecord(server.headers) ?? {}; |
| const mergedHeaders: Record<string, string | null> = { |
| ...existingHeaders, |
| ...authHeaders, |
| }; |
|
|
| const storedHeaders = storedHeadersByServer.get(name); |
| if (storedHeaders) { |
| for (const key of Object.keys(storedHeaders)) { |
| if (!(key in mergedHeaders)) mergedHeaders[key] = null; |
| } |
| } |
|
|
| delete nextServer.auth; |
| if (Object.keys(mergedHeaders).length > 0) { |
| nextServer.headers = mergedHeaders; |
| } else { |
| delete nextServer.headers; |
| } |
| return [name, nextServer]; |
| }), |
| ); |
|
|
| return hasWrapper ? { ...value, mcpServers: converted } : converted; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const getSkillEnablement = (response: SettingsApiResponse): SkillEnablement => { |
| const prefs = response.misc_settings?.app_preferences; |
| const disabled = prefs?.disabled_skills; |
| const enabled = prefs?.enabled_skills; |
| return { |
| disabledSkills: Array.isArray(disabled) ? disabled : [], |
| enabledSkills: Array.isArray(enabled) ? enabled : undefined, |
| }; |
| }; |
|
|
| |
| |
| |
| const transformApiResponse = ( |
| response: SettingsApiResponse, |
| ): Partial<Settings> => { |
| const agentSettings = response.agent_settings ?? {}; |
| const conversationSettings = response.conversation_settings ?? {}; |
|
|
| const partial: Partial<Settings> = { |
| agent_settings: agentSettings, |
| conversation_settings: conversationSettings, |
| llm_api_key_set: response.llm_api_key_is_set, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const prefs = response.misc_settings?.app_preferences; |
| if (prefs) { |
| for (const key of APP_PREFERENCE_FIELDS) { |
| const value = prefs[key]; |
| if (value !== undefined) { |
| (partial as Record<string, unknown>)[key] = value; |
| } |
| } |
| } |
|
|
| return partial; |
| }; |
|
|
| |
| |
| |
| |
| const syncDerivedSettings = (settings: Partial<Settings>): Settings => { |
| const agentSettings = mergeRecords( |
| DEFAULT_SETTINGS.agent_settings ?? {}, |
| settings.agent_settings ?? {}, |
| ); |
| const conversationSettings = mergeRecords( |
| DEFAULT_SETTINGS.conversation_settings ?? {}, |
| settings.conversation_settings ?? {}, |
| ); |
|
|
| const merged = { |
| ...deepClone(DEFAULT_SETTINGS), |
| ...settings, |
| provider_tokens_set: { |
| ...(DEFAULT_SETTINGS.provider_tokens_set ?? {}), |
| ...(settings.provider_tokens_set ?? {}), |
| }, |
| agent_settings: agentSettings, |
| conversation_settings: conversationSettings, |
| } as Settings; |
|
|
| const llm = agentSettings.llm as Record<string, SettingsValue> | undefined; |
| const condenser = agentSettings.condenser as |
| | Record<string, SettingsValue> |
| | undefined; |
|
|
| if (typeof agentSettings.agent === "string") { |
| merged.agent = agentSettings.agent; |
| } |
| if (typeof llm?.model === "string" && llm.model.length > 0) { |
| merged.llm_model = llm.model; |
| } |
| if (typeof llm?.base_url === "string") { |
| merged.llm_base_url = llm.base_url; |
| } |
| |
| |
| if (typeof condenser?.enabled === "boolean") { |
| merged.enable_default_condenser = condenser.enabled; |
| } |
| if (typeof condenser?.max_size === "number") { |
| merged.condenser_max_size = condenser.max_size; |
| } |
| if (typeof conversationSettings.confirmation_mode === "boolean") { |
| merged.confirmation_mode = conversationSettings.confirmation_mode; |
| } |
| if ( |
| typeof conversationSettings.security_analyzer === "string" || |
| conversationSettings.security_analyzer === null |
| ) { |
| merged.security_analyzer = conversationSettings.security_analyzer as |
| | string |
| | null; |
| } |
| if (typeof conversationSettings.max_iterations === "number") { |
| merged.max_iterations = conversationSettings.max_iterations; |
| } |
|
|
| merged.search_api_key_set = !!merged.search_api_key; |
|
|
| return merged; |
| }; |
|
|
| class SettingsService { |
| |
| |
| |
| |
| |
| |
| |
| |
| static async fetchSettingsFromApi( |
| exposeSecrets?: ExposeSecretsMode, |
| ): Promise<SettingsApiResponse> { |
| return withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).getSettings({ |
| exposeSecrets, |
| }), |
| ) as Promise<SettingsApiResponse>; |
| } |
|
|
| |
| |
| |
| |
| static async getSettings(): Promise<Settings> { |
| |
| |
| |
| |
| |
| |
| if (getActiveBackend().backend.kind === "cloud") { |
| try { |
| const cloud = await withRetry(() => fetchCloudSettings()); |
| return syncDerivedSettings(cloud); |
| } catch (error) { |
| console.warn("Failed to fetch cloud settings, using defaults:", error); |
| return syncDerivedSettings({}); |
| } |
| } |
|
|
| |
| if (isCacheValid() && settingsCache.redacted) { |
| return syncDerivedSettings(transformApiResponse(settingsCache.redacted)); |
| } |
|
|
| try { |
| const response = await this.fetchSettingsFromApi(); |
| settingsCache.redacted = response; |
| settingsCache.timestamp = Date.now(); |
| return syncDerivedSettings(transformApiResponse(response)); |
| } catch (error) { |
| |
| console.warn("Failed to fetch settings from API, using defaults:", error); |
| return syncDerivedSettings({}); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| static async getSettingsForConversation(): Promise<{ |
| agentSettings: Record<string, SettingsValue>; |
| conversationSettings: Record<string, SettingsValue>; |
| secretsEncrypted: boolean; |
| skillEnablement: SkillEnablement; |
| }> { |
| |
| if (isCacheValid() && settingsCache.encrypted) { |
| return { |
| agentSettings: settingsCache.encrypted.agent_settings, |
| conversationSettings: settingsCache.encrypted.conversation_settings, |
| secretsEncrypted: true, |
| skillEnablement: getSkillEnablement(settingsCache.encrypted), |
| }; |
| } |
|
|
| |
| |
| const response = await this.fetchSettingsFromApi("encrypted"); |
| settingsCache.encrypted = response; |
| if (!settingsCache.timestamp) { |
| settingsCache.timestamp = Date.now(); |
| } |
| return { |
| agentSettings: response.agent_settings, |
| conversationSettings: response.conversation_settings, |
| secretsEncrypted: true, |
| skillEnablement: getSkillEnablement(response), |
| }; |
| } |
|
|
| static async getSettingsSchema(): Promise<SettingsSchema> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| return (await fetchCloudSettingsSchema()) as SettingsSchema; |
| } |
| return (await new SettingsClient( |
| getAgentServerClientOptions(), |
| ).getAgentSchema()) as SettingsSchema; |
| } |
|
|
| static async getConversationSettingsSchema(): Promise<SettingsSchema> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| return (await fetchCloudConversationSettingsSchema()) as SettingsSchema; |
| } |
| return (await new SettingsClient( |
| getAgentServerClientOptions(), |
| ).getConversationSchema()) as SettingsSchema; |
| } |
|
|
| |
| |
| |
| |
| |
| static async patchMcpConfig(patch: MCPConfigPatch): Promise<boolean> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| const mcpConfig = await cloudCompatibleMcpConfig(patch); |
| await withRetry(() => |
| saveCloudSettings({ |
| agent_settings_diff: { mcp_config: mcpConfig as SettingsValue }, |
| }), |
| ); |
| } else { |
| await withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).updateSettings({ |
| agent_settings_diff: { mcp_config: patch }, |
| }), |
| ); |
| } |
| clearCache(); |
| return true; |
| } |
|
|
| static async patchMcpServer( |
| settingsKey: string, |
| patch: MCPServerPatch, |
| ): Promise<boolean> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| return SettingsService.patchMcpConfig({ [settingsKey]: patch }); |
| } |
| await withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).patchMcpServer( |
| settingsKey, |
| patch, |
| ), |
| ); |
| clearCache(); |
| return true; |
| } |
|
|
| static async createMcpServer( |
| settingsKey: string, |
| server: MCPServer, |
| ): Promise<boolean> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| return SettingsService.patchMcpConfig({ [settingsKey]: server }); |
| } |
| await withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).createMcpServer( |
| settingsKey, |
| server, |
| ), |
| ); |
| clearCache(); |
| return true; |
| } |
|
|
| static async deleteMcpServer(settingsKey: string): Promise<boolean> { |
| if (getActiveBackend().backend.kind === "cloud") { |
| return SettingsService.patchMcpConfig({ [settingsKey]: null }); |
| } |
| await withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).deleteMcpServer( |
| settingsKey, |
| ), |
| ); |
| clearCache(); |
| return true; |
| } |
|
|
| |
| |
| |
| |
| static async saveSettings( |
| settings: Partial<Settings> & Record<string, unknown>, |
| ): Promise<boolean> { |
| |
| |
| |
| |
| |
| |
| const { extracted: appPreferences, rest } = extractAppPreferences( |
| settings as Record<string, unknown>, |
| ); |
| const hasAppPreferences = Object.keys(appPreferences).length > 0; |
|
|
| const payload: SettingsUpdateRequest = {}; |
|
|
| |
| const agentSettingsDiff = rest.agent_settings_diff as |
| | Record<string, SettingsValue> |
| | undefined; |
| if (agentSettingsDiff && Object.keys(agentSettingsDiff).length > 0) { |
| payload.agent_settings_diff = agentSettingsDiff; |
| } |
|
|
| |
| const conversationSettingsDiff = rest.conversation_settings_diff as |
| | Record<string, SettingsValue> |
| | undefined; |
| if ( |
| conversationSettingsDiff && |
| Object.keys(conversationSettingsDiff).length > 0 |
| ) { |
| payload.conversation_settings_diff = conversationSettingsDiff; |
| } |
|
|
| if (hasAppPreferences) { |
| payload.misc_settings_diff = { app_preferences: appPreferences }; |
| } |
|
|
| const isCloud = getActiveBackend().backend.kind === "cloud"; |
| if (isCloud) { |
| const hasCloudWork = |
| !!payload.agent_settings_diff || |
| !!payload.conversation_settings_diff || |
| hasAppPreferences; |
| if (!hasCloudWork) { |
| return true; |
| } |
| |
| |
| |
| const cloudPayload: Parameters<typeof saveCloudSettings>[0] = {}; |
| if (payload.agent_settings_diff) { |
| cloudPayload.agent_settings_diff = { ...payload.agent_settings_diff }; |
| if ("mcp_config" in cloudPayload.agent_settings_diff) { |
| cloudPayload.agent_settings_diff.mcp_config = |
| (await cloudCompatibleMcpConfig( |
| cloudPayload.agent_settings_diff.mcp_config, |
| )) as SettingsValue; |
| } |
| } |
| if (payload.conversation_settings_diff) { |
| cloudPayload.conversation_settings_diff = |
| payload.conversation_settings_diff; |
| } |
| if (hasAppPreferences) { |
| |
| |
| |
| cloudPayload.app_preferences = appPreferences; |
| } |
| await withRetry(() => saveCloudSettings(cloudPayload)); |
| } else { |
| |
| |
| const hasLocalDiffs = |
| !!payload.agent_settings_diff || |
| !!payload.conversation_settings_diff || |
| !!payload.misc_settings_diff; |
| if (!hasLocalDiffs) { |
| return true; |
| } |
| await withRetry(() => |
| new SettingsClient(getAgentServerClientOptions()).updateSettings( |
| payload, |
| ), |
| ); |
| } |
|
|
| |
| clearCache(); |
|
|
| return true; |
| } |
|
|
| |
| |
| |
| |
| static invalidateCache(): void { |
| clearCache(); |
| } |
| } |
|
|
| export default SettingsService; |
|
|