From a47a9826b0142a78236262a1afcae8b301fac5c5 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 30 Apr 2026 20:13:25 +0000 Subject: [PATCH 1/5] fix: Fall back to sdk_version when version is unknown The agent-server binary may report version='unknown' when build metadata isn't injected at build time. This breaks the version compatibility check even though sdk_version is valid. Fall back to sdk_version when version is missing or 'unknown'. The SDK version is reliable (comes from the installed package) and represents the actual API compatibility level. --- src/api/agent-server-compatibility.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts index a6d0fd860..a6d5adba0 100644 --- a/src/api/agent-server-compatibility.ts +++ b/src/api/agent-server-compatibility.ts @@ -6,7 +6,14 @@ const AGENT_SERVER_INFO_TIMEOUT_MS = 5000; const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/; -const getServerVersion = (serverInfo: ServerInfo): string => serverInfo.version; +const getServerVersion = (serverInfo: ServerInfo): string => { + // Fall back to sdk_version when version is unknown/missing + // (common in dev builds or when build metadata isn't injected) + if (serverInfo.version && serverInfo.version !== "unknown") { + return serverInfo.version; + } + return serverInfo.sdk_version ?? serverInfo.version; +}; const parseSemver = ( version: string | null, From fcaef68bd91df92730f724fc321ad38fe322f62f Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 1 May 2026 00:28:33 +0000 Subject: [PATCH 2/5] feat: Persist settings via agent-server REST API instead of localStorage only - Update SettingsService to use typescript-client SettingsClient for CRUD - Settings are now fetched from and saved to agent-server /api/settings - localStorage acts as fallback/cache for offline scenarios - Update SecretsService to use SettingsClient for custom secrets - Update typescript-client dependency to include settings CRUD endpoints - Fix tests to properly mock async saveSettings - Update AGENTS.md with settings persistence architecture docs The agent-server persists settings to ~/.openhands/settings.json and secrets to ~/.openhands/secrets.json with API key encryption. --- AGENTS.md | 8 +- .../modals/settings/settings-form.test.tsx | 46 +++++--- package-lock.json | 6 +- package.json | 2 +- src/api/secrets-service.ts | 104 +++++++++++------- .../settings-service/settings-service.api.ts | 80 ++++++++++++-- 6 files changed, 174 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f03fd29e..0c38c1329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,13 @@ - A successful end-to-end live run in this environment required a real LLM config (`LLM_MODEL` + `LLM_API_KEY`). The default `litellm_proxy/...` model with no `llm_api_key` failed at runtime with a `litellm.AuthenticationError`. -- Git provider token persistence note: this direct-agent-server frontend now persists `Settings > Git` provider tokens locally in browser storage instead of posting to an app-backend secrets route. `src/api/secrets-service.ts` writes the token payload to localStorage, mirrors provider hosts into `provider_tokens_set` through `SettingsService.saveSettings()`, and `use-delete-git-providers` clears that local state. +- **Settings persistence architecture**: Settings are now persisted via the agent-server REST API (`/api/settings` endpoints) instead of relying solely on localStorage. The `SettingsService` in `src/api/settings-service/settings-service.api.ts` uses the `@openhands/typescript-client` `SettingsClient` for CRUD operations: + - `getSettings()` fetches from the agent-server with localStorage as fallback + - `saveSettings()` posts diffs to the server and updates the local cache + - The local cache key `openhands-agent-server-settings` remains for offline scenarios and faster initial loads + - The agent-server persists settings to `~/.openhands/settings.json` on disk +- **Secrets persistence**: Custom secrets (`Settings > Secrets`) are now managed via the agent-server's `SettingsClient.createSecret()`, `listSecrets()`, `deleteSecret()` methods. The server encrypts API keys using the SDK's Cipher utility and stores them at `~/.openhands/secrets.json`. +- Git provider token persistence note: this direct-agent-server frontend still persists `Settings > Git` provider tokens locally in browser storage (`openhands-agent-server-git-provider-tokens`) while mirroring host metadata into settings via `SettingsService.saveSettings()`. - Agent server connection settings now live at `Settings > Agent Server` (`/settings/agent-server`). The page reads deployment defaults from `VITE_BACKEND_BASE_URL` / `VITE_SESSION_API_KEY`, saves user overrides in the `openhands-agent-server-config` localStorage key, and must stay reachable even when the backend compatibility probe fails so users can recover from missing or wrong backend configuration. - README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real `openhands-agent-server` (clone, install backend, optional `.env`, run `npm run dev`). diff --git a/__tests__/components/shared/modals/settings/settings-form.test.tsx b/__tests__/components/shared/modals/settings/settings-form.test.tsx index 1ebdab88c..0a5881d73 100644 --- a/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; @@ -13,6 +13,8 @@ describe("SettingsForm", () => { beforeEach(() => { vi.clearAllMocks(); + // Mock saveSettings to resolve immediately (simulates server response) + saveSettingsSpy.mockResolvedValue(true); }); it("should save the user settings and close the modal when submitted outside a conversation route", async () => { @@ -26,16 +28,21 @@ describe("SettingsForm", () => { await user.click(screen.getByTestId("save-settings-button")); - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - agent_settings_diff: expect.objectContaining({ - llm: expect.objectContaining({ - model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"), + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + agent_settings_diff: expect.objectContaining({ + llm: expect.objectContaining({ + model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"), + }), }), }), - }), - ); - expect(onCloseMock).toHaveBeenCalled(); + ); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalled(); + }); }); it("should confirm before saving when submitted from a conversation route", async () => { @@ -60,15 +67,20 @@ describe("SettingsForm", () => { await user.click(confirmButton); - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - agent_settings_diff: expect.objectContaining({ - llm: expect.objectContaining({ - model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"), + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + agent_settings_diff: expect.objectContaining({ + llm: expect.objectContaining({ + model: getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"), + }), }), }), - }), - ); - expect(onCloseMock).toHaveBeenCalled(); + ); + }); + + await waitFor(() => { + expect(onCloseMock).toHaveBeenCalled(); + }); }); }); diff --git a/package-lock.json b/package-lock.json index e67d46eb5..840ac416b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@heroui/styles": "3.0.3", "@microlink/react-json-view": "1.31.18", "@monaco-editor/react": "4.7.0", - "@openhands/typescript-client": "github:OpenHands/typescript-client#7d20f119d68893903c3eab7070416e6317f72716", + "@openhands/typescript-client": "github:OpenHands/typescript-client#268133bac491b66d52070c76b4a310967eddc0ce", "@react-router/node": "7.14.1", "@react-router/serve": "7.14.1", "@tailwindcss/vite": "4.2.2", @@ -1875,8 +1875,8 @@ }, "node_modules/@openhands/typescript-client": { "version": "0.1.1", - "resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#7d20f119d68893903c3eab7070416e6317f72716", - "integrity": "sha512-WPm20BYImlFilbjKBXkHSa1EBT486Rc3vbJFSAMY/LHm7/78rMuKF8GO0uq0FhscM+q+vocJ62bYA5vC8Y2Zpw==", + "resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#268133bac491b66d52070c76b4a310967eddc0ce", + "integrity": "sha512-WdJDv0tvDbnyXcx1tK3f59uvaFcORTC08zrwEIvILDbVsed6TLnCQUTgQY/VefcYJ8oS64fY0c7tSCDyEcG6Ww==", "license": "MIT", "dependencies": { "@openrouter/sdk": "^0.12.21", diff --git a/package.json b/package.json index 8a931be14..78d718b2a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@heroui/styles": "3.0.3", "@microlink/react-json-view": "1.31.18", "@monaco-editor/react": "4.7.0", - "@openhands/typescript-client": "github:OpenHands/typescript-client#7d20f119d68893903c3eab7070416e6317f72716", + "@openhands/typescript-client": "github:OpenHands/typescript-client#268133bac491b66d52070c76b4a310967eddc0ce", "@react-router/node": "7.14.1", "@react-router/serve": "7.14.1", "@tailwindcss/vite": "4.2.2", diff --git a/src/api/secrets-service.ts b/src/api/secrets-service.ts index f1dfc0080..2de346c9d 100644 --- a/src/api/secrets-service.ts +++ b/src/api/secrets-service.ts @@ -1,7 +1,6 @@ import SettingsService from "./settings-service/settings-service.api"; -import { openHands } from "./open-hands-axios"; +import { createSettingsClient } from "./typescript-client"; import { - CustomSecret, CustomSecretPage, CustomSecretWithoutValue, SearchSecretsParams, @@ -87,32 +86,43 @@ const buildProviderTokensSet = ( export class SecretsService { /** * Search/list custom secrets with pagination support. - * Uses the new V1 API endpoint: GET /api/v1/secrets/search + * Uses the agent-server settings client for local persistence. */ static async searchSecrets( params: SearchSecretsParams = {}, ): Promise { - const queryParams = new URLSearchParams(); + try { + const client = createSettingsClient(); + const response = await client.listSecrets(); + + // Filter by name if requested + let items = response.secrets.map((s) => ({ + name: s.name, + description: s.description ?? undefined, + })); + + if (params.name__contains) { + const query = params.name__contains.toLowerCase(); + items = items.filter((s) => s.name.toLowerCase().includes(query)); + } - if (params.name__contains) { - queryParams.set("name__contains", params.name__contains); - } - if (params.page_id) { - queryParams.set("page_id", params.page_id); + // Simple pagination (agent-server doesn't have built-in pagination) + const limit = params.limit ?? 100; + const startIndex = params.page_id ? parseInt(params.page_id, 10) : 0; + const paginatedItems = items.slice(startIndex, startIndex + limit); + const hasMore = startIndex + limit < items.length; + + return { + items: paginatedItems, + next_page_id: hasMore ? String(startIndex + limit) : null, + }; + } catch { + return { items: [], next_page_id: null }; } - if (params.limit) { - queryParams.set("limit", params.limit.toString()); - } - - const queryString = queryParams.toString(); - const url = `/api/v1/secrets/search${queryString ? `?${queryString}` : ""}`; - - const { data } = await openHands.get(url); - return data; } /** - * @deprecated Use searchSecrets instead. This method uses the deprecated V0 API. + * Get all secrets (names and descriptions only, no values). */ static async getSecrets(): Promise { const allSecrets: CustomSecretWithoutValue[] = []; @@ -131,30 +141,48 @@ export class SecretsService { return allSecrets; } + /** + * Create a new custom secret via the agent-server. + */ static async createSecret(name: string, value: string, description?: string) { - const secret: CustomSecret = { - name, - value, - description, - }; - - const { status } = await openHands.post("/api/v1/secrets", secret); - return status === 201; + try { + const client = createSettingsClient(); + await client.createSecret({ name, value, description: description ?? null }); + return true; + } catch { + return false; + } } - static async updateSecret(id: string, name: string, description?: string) { - const secret: CustomSecretWithoutValue = { - name, - description, - }; - - const { status } = await openHands.put(`/api/v1/secrets/${id}`, secret); - return status === 200; + /** + * Update a secret's metadata (description). For agent-server, we need to + * re-create the secret with the same value since we can't update in place + * without the value. + */ + static async updateSecret(_id: string, name: string, description?: string) { + try { + // For agent-server, we can only update by re-creating with a new value + // This is a limitation - in practice, users should delete and recreate + const client = createSettingsClient(); + const currentValue = await client.getSecretValue(name); + await client.createSecret({ name, value: currentValue, description: description ?? null }); + return true; + } catch { + return false; + } } - static async deleteSecret(id: string) { - const { status } = await openHands.delete(`/api/v1/secrets/${id}`); - return status === 200; + /** + * Delete a custom secret via the agent-server. + */ + static async deleteSecret(name: string) { + try { + const client = createSettingsClient(); + await client.deleteSecret(name); + return true; + } catch { + return false; + } } static async addGitProvider( diff --git a/src/api/settings-service/settings-service.api.ts b/src/api/settings-service/settings-service.api.ts index e29fe93d8..e9b341655 100644 --- a/src/api/settings-service/settings-service.api.ts +++ b/src/api/settings-service/settings-service.api.ts @@ -2,7 +2,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings, SettingsSchema, SettingsValue } from "#/types/settings"; import { createSettingsClient } from "../typescript-client"; -const STORAGE_KEY = "openhands-agent-server-settings"; +const LOCAL_CACHE_KEY = "openhands-agent-server-settings"; const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; @@ -11,13 +11,16 @@ const mergeRecords = ( next: Record | null | undefined, ) => ({ ...(base ?? {}), ...(next ?? {}) }); -const readStoredSettings = (): Partial => { +/** + * Read cached settings from localStorage (used as fallback when server unavailable). + */ +const readLocalCache = (): Partial => { if (typeof window === "undefined") { return {}; } try { - const raw = window.localStorage.getItem(STORAGE_KEY); + const raw = window.localStorage.getItem(LOCAL_CACHE_KEY); if (!raw) return {}; return (JSON.parse(raw) as Partial) ?? {}; } catch { @@ -25,6 +28,19 @@ const readStoredSettings = (): Partial => { } }; +/** + * Write settings to localStorage as a local cache. + */ +const writeLocalCache = (settings: Settings) => { + if (typeof window === "undefined") return; + window.localStorage.setItem(LOCAL_CACHE_KEY, JSON.stringify(settings)); +}; + +/** + * Converts server response format to frontend Settings format. + * The server stores `agent_settings` and `conversation_settings` as nested records, + * but the frontend expects top-level fields like `llm_model`, `agent`, etc. + */ const syncDerivedSettings = (settings: Partial): Settings => { const agentSettings = mergeRecords( DEFAULT_SETTINGS.agent_settings ?? {}, @@ -94,14 +110,34 @@ const syncDerivedSettings = (settings: Partial): Settings => { return merged; }; -const writeStoredSettings = (settings: Settings) => { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); -}; - class SettingsService { + /** + * Get settings from the agent server, with local cache fallback. + * The server persists settings to disk, while localStorage acts as a cache + * for faster initial loads and offline scenarios. + */ static async getSettings(): Promise { - return syncDerivedSettings(readStoredSettings()); + try { + const client = createSettingsClient(); + const response = await client.getSettings(); + + // Convert server response to frontend format + const fromServer: Partial = { + agent_settings: response.agent_settings, + conversation_settings: response.conversation_settings, + llm_api_key_set: response.llm_api_key_is_set, + }; + + const merged = syncDerivedSettings(fromServer); + + // Update local cache + writeLocalCache(merged); + + return merged; + } catch { + // Fallback to local cache if server unavailable + return syncDerivedSettings(readLocalCache()); + } } static async getSettingsSchema(): Promise { @@ -112,6 +148,10 @@ class SettingsService { return (await createSettingsClient().getConversationSchema()) as SettingsSchema; } + /** + * Save settings to the agent server. + * Sends only the diff (changed fields) to the server, which merges with existing. + */ static async saveSettings( settings: Partial & Record, ): Promise { @@ -185,9 +225,25 @@ class SettingsService { delete nextSettings.agent_settings_diff; delete nextSettings.conversation_settings_diff; - const merged = syncDerivedSettings(nextSettings); - writeStoredSettings(merged); - return true; + try { + // Send to agent server for persistence + const client = createSettingsClient(); + await client.updateSettings({ + agent_settings_diff: agentSettingsDiff, + conversation_settings_diff: conversationSettingsDiff, + }); + + // Update local cache on success + const merged = syncDerivedSettings(nextSettings); + writeLocalCache(merged); + + return true; + } catch { + // Fallback: save to local cache only if server unavailable + const merged = syncDerivedSettings(nextSettings); + writeLocalCache(merged); + return true; + } } } From f9964aec464f78f47244f629d6c32458de2fcb17 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 4 May 2026 18:45:30 +0000 Subject: [PATCH 3/5] feat: Use server-side settings merge for conversation start - Update typescript-client to 7d20f119d68893903c3eab7070416e6317f72716 - Remove api_key from start conversation request payload - Agent-server now fills in LLM credentials from persisted settings - This avoids exposing secrets in the request payload The agent-server's _merge_request_with_persisted_settings() merges the start conversation request with persisted settings, filling in missing LLM configuration (api_key, model, base_url) from the server's settings.json file. Co-authored-by: openhands --- package-lock.json | 6 +++--- package.json | 2 +- src/api/agent-server-adapter.ts | 10 ++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28e9a3340..557f4e6bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@heroui/styles": "3.0.3", "@microlink/react-json-view": "1.31.18", "@monaco-editor/react": "4.7.0", - "@openhands/typescript-client": "github:OpenHands/typescript-client#268133bac491b66d52070c76b4a310967eddc0ce", + "@openhands/typescript-client": "github:OpenHands/typescript-client#7d20f119d68893903c3eab7070416e6317f72716", "@react-router/node": "7.14.1", "@react-router/serve": "7.14.1", "@tailwindcss/vite": "4.2.2", @@ -1876,8 +1876,8 @@ }, "node_modules/@openhands/typescript-client": { "version": "0.1.1", - "resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#268133bac491b66d52070c76b4a310967eddc0ce", - "integrity": "sha512-WdJDv0tvDbnyXcx1tK3f59uvaFcORTC08zrwEIvILDbVsed6TLnCQUTgQY/VefcYJ8oS64fY0c7tSCDyEcG6Ww==", + "resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#7d20f119d68893903c3eab7070416e6317f72716", + "integrity": "sha512-WPm20BYImlFilbjKBXkHSa1EBT486Rc3vbJFSAMY/LHm7/78rMuKF8GO0uq0FhscM+q+vocJ62bYA5vC8Y2Zpw==", "license": "MIT", "dependencies": { "@openrouter/sdk": "^0.12.21", diff --git a/package.json b/package.json index 98569333f..08376a587 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@heroui/styles": "3.0.3", "@microlink/react-json-view": "1.31.18", "@monaco-editor/react": "4.7.0", - "@openhands/typescript-client": "github:OpenHands/typescript-client#268133bac491b66d52070c76b4a310967eddc0ce", + "@openhands/typescript-client": "github:OpenHands/typescript-client#7d20f119d68893903c3eab7070416e6317f72716", "@react-router/node": "7.14.1", "@react-router/serve": "7.14.1", "@tailwindcss/vite": "4.2.2", diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index aa577b9c5..c7ee6f41a 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -242,12 +242,10 @@ function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { llm.model = typeof llm.model === "string" ? llm.model : DEFAULT_SETTINGS.llm_model; - const apiKey = normalizeSecretString(llm.api_key); - if (apiKey) { - llm.api_key = apiKey; - } else { - delete llm.api_key; - } + // Note: We intentionally do NOT send api_key in the start conversation request. + // The agent-server will fill it in from persisted settings via server-side merge. + // This avoids exposing secrets in the request payload. + delete llm.api_key; const baseUrl = normalizeSecretString(llm.base_url); if (baseUrl) { From c4536502811157ec2717dfcb5fbb0c70d9b5446e Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 4 May 2026 19:15:59 +0000 Subject: [PATCH 4/5] send minimal payload --- __tests__/api/agent-server-adapter.test.ts | 167 ++--------- src/api/agent-server-adapter.ts | 274 ++---------------- .../v1-conversation-service.api.ts | 11 +- 3 files changed, 63 insertions(+), 389 deletions(-) diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index c5a742f25..fff897ca3 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -6,173 +6,66 @@ import { toV1AppConversation, type DirectConversationInfo, } from "#/api/agent-server-adapter"; -import { DEFAULT_SETTINGS } from "#/services/settings"; - -const { mockGetAgentServerWorkingDir } = vi.hoisted(() => ({ - mockGetAgentServerWorkingDir: vi.fn( - () => "/workspace/project/agent-server-gui", - ), -})); vi.mock("#/api/agent-server-config", () => ({ getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"), getAgentServerSessionApiKey: vi.fn(() => null), - getAgentServerWorkingDir: mockGetAgentServerWorkingDir, - getConfiguredWorkerUrls: vi.fn(() => []), + getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-server-gui"), })); describe("buildStartConversationRequest", () => { - it("uses nested settings as the source of truth and keeps SDK tool names", () => { + it("builds a minimal payload with only initial_message when query is provided", () => { const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - llm_model: "stale-top-level-model", - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent: "CodeActAgent", - llm: { - model: "nested-model", - api_key: " nested-key ", - base_url: " https://nested.example.com ", - }, - condenser: { - enabled: true, - max_size: 120, - }, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - max_iterations: 123, - }, - }, query: "hello", - }) as { - agent: Record & { - llm: Record; - tools: Array<{ name: string; params: Record }>; - }; - workspace: { working_dir: string }; - initial_message: { content: Array<{ text: string }> }; - max_iterations: number; - }; - - expect(payload.agent.llm).toMatchObject({ - model: "nested-model", - api_key: "nested-key", - base_url: "https://nested.example.com", - }); - expect(payload.agent.condenser).toEqual({ - kind: "LLMSummarizingCondenser", - llm: { - model: "nested-model", - api_key: "nested-key", - base_url: "https://nested.example.com", - usage_id: "condenser", - }, - max_size: 120, - }); - expect(payload.agent.tools).toEqual([ - { name: "terminal", params: {} }, - { name: "file_editor", params: {} }, - { name: "task_tracker", params: {} }, - { name: "browser_tool_set", params: {} }, - ]); - expect(payload.agent.agent).toBeUndefined(); - expect(payload.workspace.working_dir).toBe( - "/workspace/project/agent-server-gui", - ); - expect(payload.max_iterations).toBe(123); - expect(payload.initial_message.content[0]?.text).toBe("hello"); - }); + }) as Record; - it("derives confirmation and security settings the same way as OpenHands", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - confirmation_mode: true, - security_analyzer: "llm", - }, - }, - }) as { - confirmation_policy: Record; - security_analyzer: Record; - }; - - expect(payload.confirmation_policy).toEqual({ - kind: "ConfirmRisky", - threshold: "HIGH", - confirm_unknown: true, - }); - expect(payload.security_analyzer).toEqual({ - kind: "LLMSecurityAnalyzer", + expect(payload.initial_message).toEqual({ + role: "user", + content: [{ type: "text", text: "hello" }], }); + // Should not include agent, workspace, or other settings - server provides them + expect(payload.agent).toBeUndefined(); + expect(payload.workspace).toBeUndefined(); + expect(payload.max_iterations).toBeUndefined(); }); - it("uses the supplied conversationId and workingDir overrides", () => { + it("includes conversation_id when provided", () => { const conversationId = "11111111-1111-4111-8111-111111111111"; - const workingDir = `/base/${conversationId}`; const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, conversationId, - workingDir, - }) as { - conversation_id?: string; - workspace: { working_dir: string }; - }; + }) as Record; expect(payload.conversation_id).toBe(conversationId); - expect(payload.workspace.working_dir).toBe(workingDir); + expect(payload.initial_message).toBeUndefined(); }); - it("forwards supported conversation runtime fields from nested settings", () => { + it("combines query and conversationInstructions into initial_message", () => { const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - hook_config: { on_start: [] }, - tool_module_qualnames: { demo_tool: "pkg.tools.demo" }, - agent_definitions: [ - { name: "reviewer", system_prompt: "be helpful" }, - ], - }, - }, + query: "Fix the bug", conversationInstructions: "Follow the repo conventions.", + }) as Record; + + expect(payload.initial_message).toEqual({ + role: "user", + content: [{ type: "text", text: "Fix the bug\n\nFollow the repo conventions." }], + }); + }); + + it("includes plugins when provided", () => { + const payload = buildStartConversationRequest({ plugins: [ { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, ], }) as Record; - expect(payload.hook_config).toEqual({ on_start: [] }); - expect(payload.tool_module_qualnames).toEqual({ - demo_tool: "pkg.tools.demo", - }); - expect(payload.agent_definitions).toEqual([ - { name: "reviewer", system_prompt: "be helpful" }, - ]); expect(payload.plugins).toEqual([ { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, ]); - expect(payload.initial_message).toEqual({ - role: "user", - content: [{ type: "text", text: "Follow the repo conventions." }], - }); + }); + + it("returns empty payload when no options are provided", () => { + const payload = buildStartConversationRequest({}); + expect(payload).toEqual({}); }); }); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index c7ee6f41a..658e6d295 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -1,11 +1,9 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; -import { Settings } from "#/types/settings"; import { V1ExecutionStatus } from "#/types/v1/core"; import { getAgentServerBaseUrl, getAgentServerSessionApiKey, getAgentServerWorkingDir, - getConfiguredWorkerUrls, } from "./agent-server-config"; import { GetHooksResponse, @@ -44,12 +42,7 @@ export interface DirectConversationInfo { } | null; } -const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"]; -const BROWSER_TOOL_SET_NAME = "browser_tool_set"; -function browserToolsEnabled() { - return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; -} export function toConversationUrl(conversationId: string): string { return `${getAgentServerBaseUrl()}/api/conversations/${conversationId}`; @@ -122,255 +115,52 @@ export function toV1ConversationPage(data: { }; } -type SettingsRecord = Record; - -const AGENT_SETTINGS_METADATA_KEYS = new Set([ - "schema_version", - "agent_kind", - "agent", -]); - -const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ - "schema_version", - "agent_settings", - "workspace", - "conversation_id", - "initial_message", - "plugins", -]); - -function toRecord(value: unknown): SettingsRecord { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - - return structuredClone(value as SettingsRecord); -} - -function normalizeSecretString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function getConversationConfirmationPolicy( - conversationSettings: SettingsRecord, -) { - if (conversationSettings.confirmation_mode !== true) { - return { kind: "NeverConfirm" }; - } - - if (conversationSettings.security_analyzer === "llm") { - return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true }; - } - - return { kind: "AlwaysConfirm" }; -} - -function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { - switch (conversationSettings.security_analyzer) { - case "llm": - return { kind: "LLMSecurityAnalyzer" }; - case "pattern": - return { kind: "PatternSecurityAnalyzer" }; - case "policy_rail": - return { kind: "PolicyRailSecurityAnalyzer" }; - default: - return undefined; - } -} - -function getAgentTools() { - const tools = DEFAULT_TOOL_NAMES.map((name) => ({ name, params: {} })); - if (browserToolsEnabled()) { - tools.push({ name: BROWSER_TOOL_SET_NAME, params: {} }); - } - return tools; -} - -function buildInitialMessage( - query?: string, - conversationInstructions?: string, -) { - const parts = [query?.trim(), conversationInstructions?.trim()].filter( - Boolean, - ); - if (parts.length === 0) { - return null; - } - - return { - role: "user", - content: [{ type: "text", text: parts.join("\n\n") }], - }; -} - -function buildCondenserConfig( - llm: SettingsRecord, - rawCondenser: unknown, -): SettingsRecord | undefined { - const condenser = toRecord(rawCondenser); - - if (condenser.enabled !== true) { - return undefined; - } - - const condenserLlm = { - ...llm, - usage_id: "condenser", - }; - - const config: SettingsRecord = { - kind: "LLMSummarizingCondenser", - llm: condenserLlm, - }; - - if (typeof condenser.max_size === "number") { - config.max_size = condenser.max_size; - } - - return config; -} - -function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { - const agentSettings = toRecord(settings.agent_settings); - const llm = toRecord(agentSettings.llm); - - llm.model = - typeof llm.model === "string" ? llm.model : DEFAULT_SETTINGS.llm_model; - - // Note: We intentionally do NOT send api_key in the start conversation request. - // The agent-server will fill it in from persisted settings via server-side merge. - // This avoids exposing secrets in the request payload. - delete llm.api_key; - - const baseUrl = normalizeSecretString(llm.base_url); - if (baseUrl) { - llm.base_url = baseUrl; - } else { - delete llm.base_url; - } - - const condenser = buildCondenserConfig(llm, agentSettings.condenser); - - AGENT_SETTINGS_METADATA_KEYS.forEach((key) => delete agentSettings[key]); - - const mcpConfig = toRecord(agentSettings.mcp_config); - if (Object.keys(mcpConfig).length === 0 || !("mcpServers" in mcpConfig)) { - delete agentSettings.mcp_config; - } - - if (condenser) { - agentSettings.condenser = condenser; - } else { - delete agentSettings.condenser; - } - - return { - ...agentSettings, - llm, - tools: getAgentTools(), - }; -} - -function createAgentFromSettings(agentSettings: SettingsRecord) { - return { - kind: "Agent", - ...agentSettings, - }; -} - -function buildConfiguredConversationSettings(options: { - settings: Settings; - query?: string; - conversationInstructions?: string; - plugins?: PluginSpec[]; - workingDir?: string; -}): SettingsRecord { - const { settings, query, conversationInstructions, plugins, workingDir } = - options; - const conversationSettings = toRecord(settings.conversation_settings); - const initialMessage = buildInitialMessage(query, conversationInstructions); - - CONVERSATION_SETTINGS_METADATA_KEYS.forEach( - (key) => delete conversationSettings[key], - ); - - return { - ...conversationSettings, - workspace: { - kind: "LocalWorkspace", - working_dir: workingDir ?? getAgentServerWorkingDir(), - }, - ...(initialMessage ? { initial_message: initialMessage } : {}), - ...(plugins?.length - ? { - plugins: plugins.map((plugin) => ({ - source: plugin.source, - ...(plugin.ref ? { ref: plugin.ref } : {}), - ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), - })), - } - : {}), - }; -} - +/** + * Build a minimal start conversation request payload. + * + * The agent-server fills in all configuration from persisted settings via + * server-side merge (_merge_request_with_persisted_settings). We only send: + * - initial_message: The user's query (if any) + * - plugins: Any plugins to load (if any) + * - conversation_id: Optional explicit ID + * + * All other settings (agent/LLM config, tools, workspace, confirmation_policy, + * max_iterations, condenser, MCP config, security_analyzer) come from + * server-side persisted settings. + */ export function buildStartConversationRequest(options: { - settings: Settings; query?: string; conversationInstructions?: string; plugins?: PluginSpec[]; conversationId?: string; - workingDir?: string; }) { - const agentSettings = buildConfiguredAgentSettings(options.settings); - const agent = createAgentFromSettings(agentSettings); - const conversationSettings = buildConfiguredConversationSettings(options); - - const payload: Record = { - agent, - workspace: conversationSettings.workspace, - confirmation_policy: - getConversationConfirmationPolicy(conversationSettings), - max_iterations: - typeof conversationSettings.max_iterations === "number" - ? conversationSettings.max_iterations - : 500, - stuck_detection: true, - autotitle: true, - }; + const payload: Record = {}; + // Add conversation ID if specified if (options.conversationId) { payload.conversation_id = options.conversationId; } - const securityAnalyzer = - getConversationSecurityAnalyzer(conversationSettings); - if (securityAnalyzer) { - payload.security_analyzer = securityAnalyzer; - } - - if (conversationSettings.initial_message) { - payload.initial_message = conversationSettings.initial_message; - } - - if (conversationSettings.plugins) { - payload.plugins = conversationSettings.plugins; - } - - if (conversationSettings.hook_config) { - payload.hook_config = conversationSettings.hook_config; - } + // Build initial message from query and instructions + const messageParts = [ + options.query?.trim(), + options.conversationInstructions?.trim(), + ].filter(Boolean); - if (conversationSettings.tool_module_qualnames) { - payload.tool_module_qualnames = conversationSettings.tool_module_qualnames; + if (messageParts.length > 0) { + payload.initial_message = { + role: "user", + content: [{ type: "text", text: messageParts.join("\n\n") }], + }; } - if (conversationSettings.agent_definitions) { - payload.agent_definitions = conversationSettings.agent_definitions; + // Add plugins if specified + if (options.plugins?.length) { + payload.plugins = options.plugins.map((plugin) => ({ + source: plugin.source, + ...(plugin.ref ? { ref: plugin.ref } : {}), + ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), + })); } return payload; diff --git a/src/api/conversation-service/v1-conversation-service.api.ts b/src/api/conversation-service/v1-conversation-service.api.ts index 9c3d704cd..4e98f565d 100644 --- a/src/api/conversation-service/v1-conversation-service.api.ts +++ b/src/api/conversation-service/v1-conversation-service.api.ts @@ -1,10 +1,6 @@ import { Provider } from "#/types/settings"; import { SuggestedTask } from "#/utils/types"; -import { - buildConversationWorkingDir, - getAgentServerBaseUrl, - getAgentServerWorkingDir, -} from "../agent-server-config"; +import { getAgentServerBaseUrl, getAgentServerWorkingDir } from "../agent-server-config"; import { DirectConversationInfo, buildStartConversationRequest, @@ -21,7 +17,6 @@ import { createRemoteWorkspace, createVSCodeClient, } from "../typescript-client"; -import SettingsService from "../settings-service/settings-service.api"; import type { GetHooksResponse, GetSkillsResponse, @@ -56,16 +51,12 @@ class V1ConversationService { conversationInstructions?: string, plugins?: PluginSpec[], ): Promise { - const settings = await SettingsService.getSettings(); const conversationId = crypto.randomUUID(); - const workingDir = buildConversationWorkingDir(conversationId); const payload = buildStartConversationRequest({ - settings, query: initialUserMsg, conversationInstructions, plugins, conversationId, - workingDir, }); const response = await createHttpClient().post( From b0609acf85880aa0543515d5197b865d1252e766 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 5 May 2026 05:49:03 +0000 Subject: [PATCH 5/5] feat: implement encrypted secrets flow for settings API - Add X-Expose-Secrets: encrypted header when fetching settings - Add secrets_encrypted: true flag to start conversation requests - Use HTTP client directly for settings CRUD (bypasses typescript-client) - Update secrets service to use HTTP client for secrets API The frontend receives cipher-encrypted secret values from the server and passes them back when starting conversations. The server decrypts them before use. Co-authored-by: openhands --- __tests__/api/agent-server-adapter.test.ts | 167 +++++++++-- src/api/agent-server-adapter.ts | 281 ++++++++++++++++-- .../v1-conversation-service.api.ts | 11 +- src/api/secrets-service.ts | 52 +++- .../settings-service/settings-service.api.ts | 36 ++- 5 files changed, 466 insertions(+), 81 deletions(-) diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index fff897ca3..c5a742f25 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -6,66 +6,173 @@ import { toV1AppConversation, type DirectConversationInfo, } from "#/api/agent-server-adapter"; +import { DEFAULT_SETTINGS } from "#/services/settings"; + +const { mockGetAgentServerWorkingDir } = vi.hoisted(() => ({ + mockGetAgentServerWorkingDir: vi.fn( + () => "/workspace/project/agent-server-gui", + ), +})); vi.mock("#/api/agent-server-config", () => ({ getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"), getAgentServerSessionApiKey: vi.fn(() => null), - getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-server-gui"), + getAgentServerWorkingDir: mockGetAgentServerWorkingDir, + getConfiguredWorkerUrls: vi.fn(() => []), })); describe("buildStartConversationRequest", () => { - it("builds a minimal payload with only initial_message when query is provided", () => { + it("uses nested settings as the source of truth and keeps SDK tool names", () => { const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + llm_model: "stale-top-level-model", + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + agent: "CodeActAgent", + llm: { + model: "nested-model", + api_key: " nested-key ", + base_url: " https://nested.example.com ", + }, + condenser: { + enabled: true, + max_size: 120, + }, + }, + conversation_settings: { + ...DEFAULT_SETTINGS.conversation_settings, + max_iterations: 123, + }, + }, query: "hello", - }) as Record; + }) as { + agent: Record & { + llm: Record; + tools: Array<{ name: string; params: Record }>; + }; + workspace: { working_dir: string }; + initial_message: { content: Array<{ text: string }> }; + max_iterations: number; + }; + + expect(payload.agent.llm).toMatchObject({ + model: "nested-model", + api_key: "nested-key", + base_url: "https://nested.example.com", + }); + expect(payload.agent.condenser).toEqual({ + kind: "LLMSummarizingCondenser", + llm: { + model: "nested-model", + api_key: "nested-key", + base_url: "https://nested.example.com", + usage_id: "condenser", + }, + max_size: 120, + }); + expect(payload.agent.tools).toEqual([ + { name: "terminal", params: {} }, + { name: "file_editor", params: {} }, + { name: "task_tracker", params: {} }, + { name: "browser_tool_set", params: {} }, + ]); + expect(payload.agent.agent).toBeUndefined(); + expect(payload.workspace.working_dir).toBe( + "/workspace/project/agent-server-gui", + ); + expect(payload.max_iterations).toBe(123); + expect(payload.initial_message.content[0]?.text).toBe("hello"); + }); - expect(payload.initial_message).toEqual({ - role: "user", - content: [{ type: "text", text: "hello" }], + it("derives confirmation and security settings the same way as OpenHands", () => { + const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { model: "nested-model" }, + }, + conversation_settings: { + ...DEFAULT_SETTINGS.conversation_settings, + confirmation_mode: true, + security_analyzer: "llm", + }, + }, + }) as { + confirmation_policy: Record; + security_analyzer: Record; + }; + + expect(payload.confirmation_policy).toEqual({ + kind: "ConfirmRisky", + threshold: "HIGH", + confirm_unknown: true, + }); + expect(payload.security_analyzer).toEqual({ + kind: "LLMSecurityAnalyzer", }); - // Should not include agent, workspace, or other settings - server provides them - expect(payload.agent).toBeUndefined(); - expect(payload.workspace).toBeUndefined(); - expect(payload.max_iterations).toBeUndefined(); }); - it("includes conversation_id when provided", () => { + it("uses the supplied conversationId and workingDir overrides", () => { const conversationId = "11111111-1111-4111-8111-111111111111"; + const workingDir = `/base/${conversationId}`; const payload = buildStartConversationRequest({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { model: "nested-model" }, + }, + }, conversationId, - }) as Record; + workingDir, + }) as { + conversation_id?: string; + workspace: { working_dir: string }; + }; expect(payload.conversation_id).toBe(conversationId); - expect(payload.initial_message).toBeUndefined(); + expect(payload.workspace.working_dir).toBe(workingDir); }); - it("combines query and conversationInstructions into initial_message", () => { + it("forwards supported conversation runtime fields from nested settings", () => { const payload = buildStartConversationRequest({ - query: "Fix the bug", + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { model: "nested-model" }, + }, + conversation_settings: { + ...DEFAULT_SETTINGS.conversation_settings, + hook_config: { on_start: [] }, + tool_module_qualnames: { demo_tool: "pkg.tools.demo" }, + agent_definitions: [ + { name: "reviewer", system_prompt: "be helpful" }, + ], + }, + }, conversationInstructions: "Follow the repo conventions.", - }) as Record; - - expect(payload.initial_message).toEqual({ - role: "user", - content: [{ type: "text", text: "Fix the bug\n\nFollow the repo conventions." }], - }); - }); - - it("includes plugins when provided", () => { - const payload = buildStartConversationRequest({ plugins: [ { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, ], }) as Record; + expect(payload.hook_config).toEqual({ on_start: [] }); + expect(payload.tool_module_qualnames).toEqual({ + demo_tool: "pkg.tools.demo", + }); + expect(payload.agent_definitions).toEqual([ + { name: "reviewer", system_prompt: "be helpful" }, + ]); expect(payload.plugins).toEqual([ { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, ]); - }); - - it("returns empty payload when no options are provided", () => { - const payload = buildStartConversationRequest({}); - expect(payload).toEqual({}); + expect(payload.initial_message).toEqual({ + role: "user", + content: [{ type: "text", text: "Follow the repo conventions." }], + }); }); }); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index 658e6d295..adafd1c14 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -1,9 +1,11 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; +import { Settings } from "#/types/settings"; import { V1ExecutionStatus } from "#/types/v1/core"; import { getAgentServerBaseUrl, getAgentServerSessionApiKey, getAgentServerWorkingDir, + getConfiguredWorkerUrls, } from "./agent-server-config"; import { GetHooksResponse, @@ -42,7 +44,12 @@ export interface DirectConversationInfo { } | null; } +const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"]; +const BROWSER_TOOL_SET_NAME = "browser_tool_set"; +function browserToolsEnabled() { + return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; +} export function toConversationUrl(conversationId: string): string { return `${getAgentServerBaseUrl()}/api/conversations/${conversationId}`; @@ -115,52 +122,268 @@ export function toV1ConversationPage(data: { }; } +type SettingsRecord = Record; + +const AGENT_SETTINGS_METADATA_KEYS = new Set([ + "schema_version", + "agent_kind", + "agent", +]); + +const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ + "schema_version", + "agent_settings", + "workspace", + "conversation_id", + "initial_message", + "plugins", +]); + +function toRecord(value: unknown): SettingsRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return structuredClone(value as SettingsRecord); +} + +function normalizeSecretString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getConversationConfirmationPolicy( + conversationSettings: SettingsRecord, +) { + if (conversationSettings.confirmation_mode !== true) { + return { kind: "NeverConfirm" }; + } + + if (conversationSettings.security_analyzer === "llm") { + return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true }; + } + + return { kind: "AlwaysConfirm" }; +} + +function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { + switch (conversationSettings.security_analyzer) { + case "llm": + return { kind: "LLMSecurityAnalyzer" }; + case "pattern": + return { kind: "PatternSecurityAnalyzer" }; + case "policy_rail": + return { kind: "PolicyRailSecurityAnalyzer" }; + default: + return undefined; + } +} + +function getAgentTools() { + const tools = DEFAULT_TOOL_NAMES.map((name) => ({ name, params: {} })); + if (browserToolsEnabled()) { + tools.push({ name: BROWSER_TOOL_SET_NAME, params: {} }); + } + return tools; +} + +function buildInitialMessage( + query?: string, + conversationInstructions?: string, +) { + const parts = [query?.trim(), conversationInstructions?.trim()].filter( + Boolean, + ); + if (parts.length === 0) { + return null; + } + + return { + role: "user", + content: [{ type: "text", text: parts.join("\n\n") }], + }; +} + +function buildCondenserConfig( + llm: SettingsRecord, + rawCondenser: unknown, +): SettingsRecord | undefined { + const condenser = toRecord(rawCondenser); + + if (condenser.enabled !== true) { + return undefined; + } + + const condenserLlm = { + ...llm, + usage_id: "condenser", + }; + + const config: SettingsRecord = { + kind: "LLMSummarizingCondenser", + llm: condenserLlm, + }; + + if (typeof condenser.max_size === "number") { + config.max_size = condenser.max_size; + } + + return config; +} + +function buildConfiguredAgentSettings(settings: Settings): SettingsRecord { + const agentSettings = toRecord(settings.agent_settings); + const llm = toRecord(agentSettings.llm); + + llm.model = + typeof llm.model === "string" ? llm.model : DEFAULT_SETTINGS.llm_model; + + const apiKey = normalizeSecretString(llm.api_key); + if (apiKey) { + llm.api_key = apiKey; + } else { + delete llm.api_key; + } + + const baseUrl = normalizeSecretString(llm.base_url); + if (baseUrl) { + llm.base_url = baseUrl; + } else { + delete llm.base_url; + } + + const condenser = buildCondenserConfig(llm, agentSettings.condenser); + + AGENT_SETTINGS_METADATA_KEYS.forEach((key) => delete agentSettings[key]); + + const mcpConfig = toRecord(agentSettings.mcp_config); + if (Object.keys(mcpConfig).length === 0 || !("mcpServers" in mcpConfig)) { + delete agentSettings.mcp_config; + } + + if (condenser) { + agentSettings.condenser = condenser; + } else { + delete agentSettings.condenser; + } + + return { + ...agentSettings, + llm, + tools: getAgentTools(), + }; +} + +function createAgentFromSettings(agentSettings: SettingsRecord) { + return { + kind: "Agent", + ...agentSettings, + }; +} + +function buildConfiguredConversationSettings(options: { + settings: Settings; + query?: string; + conversationInstructions?: string; + plugins?: PluginSpec[]; + workingDir?: string; +}): SettingsRecord { + const { settings, query, conversationInstructions, plugins, workingDir } = + options; + const conversationSettings = toRecord(settings.conversation_settings); + const initialMessage = buildInitialMessage(query, conversationInstructions); + + CONVERSATION_SETTINGS_METADATA_KEYS.forEach( + (key) => delete conversationSettings[key], + ); + + return { + ...conversationSettings, + workspace: { + kind: "LocalWorkspace", + working_dir: workingDir ?? getAgentServerWorkingDir(), + }, + ...(initialMessage ? { initial_message: initialMessage } : {}), + ...(plugins?.length + ? { + plugins: plugins.map((plugin) => ({ + source: plugin.source, + ...(plugin.ref ? { ref: plugin.ref } : {}), + ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), + })), + } + : {}), + }; +} + /** - * Build a minimal start conversation request payload. + * Build a start conversation request payload from settings. * - * The agent-server fills in all configuration from persisted settings via - * server-side merge (_merge_request_with_persisted_settings). We only send: - * - initial_message: The user's query (if any) - * - plugins: Any plugins to load (if any) - * - conversation_id: Optional explicit ID - * - * All other settings (agent/LLM config, tools, workspace, confirmation_policy, - * max_iterations, condenser, MCP config, security_analyzer) come from - * server-side persisted settings. + * The frontend fetches settings with `X-Expose-Secrets: encrypted`, receiving + * cipher-encrypted secret values. When starting a conversation, we include + * `secrets_encrypted: true` to tell the server to decrypt the secrets + * (e.g., LLM API key) before use. */ export function buildStartConversationRequest(options: { + settings: Settings; query?: string; conversationInstructions?: string; plugins?: PluginSpec[]; conversationId?: string; + workingDir?: string; }) { - const payload: Record = {}; + const agentSettings = buildConfiguredAgentSettings(options.settings); + const agent = createAgentFromSettings(agentSettings); + const conversationSettings = buildConfiguredConversationSettings(options); + + const payload: Record = { + agent, + workspace: conversationSettings.workspace, + confirmation_policy: + getConversationConfirmationPolicy(conversationSettings), + max_iterations: + typeof conversationSettings.max_iterations === "number" + ? conversationSettings.max_iterations + : 500, + stuck_detection: true, + autotitle: true, + // Tell server that secrets (e.g., LLM api_key) are cipher-encrypted + // and need to be decrypted before use + secrets_encrypted: true, + }; - // Add conversation ID if specified if (options.conversationId) { payload.conversation_id = options.conversationId; } - // Build initial message from query and instructions - const messageParts = [ - options.query?.trim(), - options.conversationInstructions?.trim(), - ].filter(Boolean); + const securityAnalyzer = + getConversationSecurityAnalyzer(conversationSettings); + if (securityAnalyzer) { + payload.security_analyzer = securityAnalyzer; + } + + if (conversationSettings.initial_message) { + payload.initial_message = conversationSettings.initial_message; + } + + if (conversationSettings.plugins) { + payload.plugins = conversationSettings.plugins; + } + + if (conversationSettings.hook_config) { + payload.hook_config = conversationSettings.hook_config; + } - if (messageParts.length > 0) { - payload.initial_message = { - role: "user", - content: [{ type: "text", text: messageParts.join("\n\n") }], - }; + if (conversationSettings.tool_module_qualnames) { + payload.tool_module_qualnames = conversationSettings.tool_module_qualnames; } - // Add plugins if specified - if (options.plugins?.length) { - payload.plugins = options.plugins.map((plugin) => ({ - source: plugin.source, - ...(plugin.ref ? { ref: plugin.ref } : {}), - ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), - })); + if (conversationSettings.agent_definitions) { + payload.agent_definitions = conversationSettings.agent_definitions; } return payload; diff --git a/src/api/conversation-service/v1-conversation-service.api.ts b/src/api/conversation-service/v1-conversation-service.api.ts index 4e98f565d..9c3d704cd 100644 --- a/src/api/conversation-service/v1-conversation-service.api.ts +++ b/src/api/conversation-service/v1-conversation-service.api.ts @@ -1,6 +1,10 @@ import { Provider } from "#/types/settings"; import { SuggestedTask } from "#/utils/types"; -import { getAgentServerBaseUrl, getAgentServerWorkingDir } from "../agent-server-config"; +import { + buildConversationWorkingDir, + getAgentServerBaseUrl, + getAgentServerWorkingDir, +} from "../agent-server-config"; import { DirectConversationInfo, buildStartConversationRequest, @@ -17,6 +21,7 @@ import { createRemoteWorkspace, createVSCodeClient, } from "../typescript-client"; +import SettingsService from "../settings-service/settings-service.api"; import type { GetHooksResponse, GetSkillsResponse, @@ -51,12 +56,16 @@ class V1ConversationService { conversationInstructions?: string, plugins?: PluginSpec[], ): Promise { + const settings = await SettingsService.getSettings(); const conversationId = crypto.randomUUID(); + const workingDir = buildConversationWorkingDir(conversationId); const payload = buildStartConversationRequest({ + settings, query: initialUserMsg, conversationInstructions, plugins, conversationId, + workingDir, }); const response = await createHttpClient().post( diff --git a/src/api/secrets-service.ts b/src/api/secrets-service.ts index 11b06fca1..e48c99595 100644 --- a/src/api/secrets-service.ts +++ b/src/api/secrets-service.ts @@ -1,5 +1,5 @@ import SettingsService from "./settings-service/settings-service.api"; -import { createSettingsClient } from "./typescript-client"; +import { createHttpClient } from "./typescript-client"; import { CustomSecretPage, CustomSecretWithoutValue, @@ -7,6 +7,22 @@ import { } from "./secrets-service.types"; import { Provider, ProviderOptions, ProviderToken } from "#/types/settings"; +/** + * Response from GET /api/settings/secrets + */ +interface SecretsListResponse { + secrets: Array<{ name: string; description?: string | null }>; +} + +/** + * Request for PUT /api/settings/secrets + */ +interface CreateSecretRequest { + name: string; + value: string; + description?: string | null; +} + const GIT_PROVIDER_STORAGE_KEY = "openhands-agent-server-git-provider-tokens"; type StoredGitProviderTokens = Partial>; @@ -96,17 +112,19 @@ const buildProviderTokensSet = ( export class SecretsService { /** * Search/list custom secrets with pagination support. - * Uses the agent-server settings client for local persistence. + * Uses the agent-server settings API for local persistence. */ static async searchSecrets( params: SearchSecretsParams = {}, ): Promise { try { - const client = createSettingsClient(); - const response = await client.listSecrets(); + const client = createHttpClient(); + const response = await client.get( + "/api/settings/secrets", + ); // Filter by name if requested - let items = response.secrets.map((s) => ({ + let items = response.data.secrets.map((s) => ({ name: s.name, description: s.description ?? undefined, })); @@ -156,8 +174,12 @@ export class SecretsService { */ static async createSecret(name: string, value: string, description?: string) { try { - const client = createSettingsClient(); - await client.createSecret({ name, value, description: description ?? null }); + const client = createHttpClient(); + await client.put("/api/settings/secrets", { + name, + value, + description: description ?? null, + }); return true; } catch { return false; @@ -173,9 +195,15 @@ export class SecretsService { try { // For agent-server, we can only update by re-creating with a new value // This is a limitation - in practice, users should delete and recreate - const client = createSettingsClient(); - const currentValue = await client.getSecretValue(name); - await client.createSecret({ name, value: currentValue, description: description ?? null }); + const client = createHttpClient(); + const valueResponse = await client.get( + `/api/settings/secrets/${name}`, + ); + await client.put("/api/settings/secrets", { + name, + value: valueResponse.data, + description: description ?? null, + }); return true; } catch { return false; @@ -187,8 +215,8 @@ export class SecretsService { */ static async deleteSecret(name: string) { try { - const client = createSettingsClient(); - await client.deleteSecret(name); + const client = createHttpClient(); + await client.delete(`/api/settings/secrets/${name}`); return true; } catch { return false; diff --git a/src/api/settings-service/settings-service.api.ts b/src/api/settings-service/settings-service.api.ts index e9b341655..9867636d5 100644 --- a/src/api/settings-service/settings-service.api.ts +++ b/src/api/settings-service/settings-service.api.ts @@ -1,9 +1,18 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { Settings, SettingsSchema, SettingsValue } from "#/types/settings"; -import { createSettingsClient } from "../typescript-client"; +import { createHttpClient, createSettingsClient } from "../typescript-client"; const LOCAL_CACHE_KEY = "openhands-agent-server-settings"; +/** + * Response from GET /api/settings + */ +interface SettingsResponse { + agent_settings: Record; + conversation_settings: Record; + llm_api_key_is_set: boolean; +} + const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; const mergeRecords = ( @@ -115,22 +124,31 @@ class SettingsService { * Get settings from the agent server, with local cache fallback. * The server persists settings to disk, while localStorage acts as a cache * for faster initial loads and offline scenarios. + * + * Uses `X-Expose-Secrets: encrypted` header to receive cipher-encrypted + * secret values. These encrypted values can be safely stored client-side + * and passed back to the server when starting conversations (with + * `secrets_encrypted: true`), where they will be decrypted. */ static async getSettings(): Promise { try { - const client = createSettingsClient(); - const response = await client.getSettings(); + const client = createHttpClient(); + const response = await client.get("/api/settings", { + headers: { + "X-Expose-Secrets": "encrypted", + }, + }); // Convert server response to frontend format const fromServer: Partial = { - agent_settings: response.agent_settings, - conversation_settings: response.conversation_settings, - llm_api_key_set: response.llm_api_key_is_set, + agent_settings: response.data.agent_settings, + conversation_settings: response.data.conversation_settings, + llm_api_key_set: response.data.llm_api_key_is_set, }; const merged = syncDerivedSettings(fromServer); - // Update local cache + // Update local cache (contains encrypted secrets) writeLocalCache(merged); return merged; @@ -227,8 +245,8 @@ class SettingsService { try { // Send to agent server for persistence - const client = createSettingsClient(); - await client.updateSettings({ + const client = createHttpClient(); + await client.patch("/api/settings", { agent_settings_diff: agentSettingsDiff, conversation_settings_diff: conversationSettingsDiff, });