From cf5d06c5057fe093e6b55554ea10d2e5d3d8ffcd Mon Sep 17 00:00:00 2001 From: HUANG <15866338256@163.com> Date: Sat, 26 Sep 2026 00:56:58 +0800 Subject: [PATCH 1/3] feat(desktop): add Tavily web search settings Signed-off-by: HUANG <15866338256@163.com> --- .changeset/tavily-settings-entry.md | 7 + WEB_SEARCH.md | 39 ++- apps/desktop/src/main/ipc/generate.ts | 35 +-- apps/desktop/src/main/onboarding-ipc.test.ts | 38 +++ apps/desktop/src/main/onboarding/register.ts | 12 + .../src/main/web-research-network.test.ts | 148 ++++++++++ apps/desktop/src/main/web-research-network.ts | 94 ++++++- .../desktop/src/main/web-research-run.test.ts | 204 ++++++++++++++ apps/desktop/src/main/web-research-run.ts | 42 +++ .../src/main/web-search-settings.test.ts | 213 ++++++++++++++ apps/desktop/src/main/web-search-settings.ts | 86 ++++++ apps/desktop/src/preload/index.ts | 9 + apps/desktop/src/preload/web-search.test.ts | 39 +++ .../renderer/src/components/Settings.test.ts | 2 + .../src/renderer/src/components/Settings.tsx | 16 +- .../components/settings/WebSearchTab.test.tsx | 266 ++++++++++++++++++ .../src/components/settings/WebSearchTab.tsx | 266 ++++++++++++++++++ apps/desktop/src/renderer/src/store.ts | 1 + packages/i18n/src/locales/en.json | 43 +++ packages/i18n/src/locales/zh-CN.json | 43 +++ packages/shared/src/index.ts | 6 + .../shared/src/web-search-settings.test.ts | 68 +++++ packages/shared/src/web-search-settings.ts | 49 ++++ 23 files changed, 1679 insertions(+), 47 deletions(-) create mode 100644 .changeset/tavily-settings-entry.md create mode 100644 apps/desktop/src/main/web-research-run.test.ts create mode 100644 apps/desktop/src/main/web-research-run.ts create mode 100644 apps/desktop/src/main/web-search-settings.test.ts create mode 100644 apps/desktop/src/main/web-search-settings.ts create mode 100644 apps/desktop/src/preload/web-search.test.ts create mode 100644 apps/desktop/src/renderer/src/components/settings/WebSearchTab.test.tsx create mode 100644 apps/desktop/src/renderer/src/components/settings/WebSearchTab.tsx create mode 100644 packages/shared/src/web-search-settings.test.ts create mode 100644 packages/shared/src/web-search-settings.ts diff --git a/.changeset/tavily-settings-entry.md b/.changeset/tavily-settings-entry.md new file mode 100644 index 000000000..56c08b61d --- /dev/null +++ b/.changeset/tavily-settings-entry.md @@ -0,0 +1,7 @@ +--- +"@open-codesign/desktop": minor +"@open-codesign/shared": patch +"@open-codesign/i18n": patch +--- + +Add bilingual Tavily web-search settings with safe key save/replace/clear operations and explicit bounded connection testing. Apply settings to new runs without restarting while retaining per-run networking consent and active-run snapshots. Keep direct webpage reading independent of Tavily credentials. diff --git a/WEB_SEARCH.md b/WEB_SEARCH.md index a9265e309..4b1de994d 100644 --- a/WEB_SEARCH.md +++ b/WEB_SEARCH.md @@ -4,7 +4,35 @@ Open CoDesign can research a slide topic, save facts/data, build slides, and del ## Configure -Finish normal model onboarding first. Close the app and add these sections to the active `config.toml` (normally `~/.config/open-codesign/config.toml`; respects `XDG_CONFIG_HOME` and custom storage locations): +Open **Settings → Web Search** (**设置 → 联网搜索**). No manual file editing or restart is needed: + +1. Paste a Tavily API key into the password field and choose **Save key**. Obtain a key from the [official Tavily dashboard](https://app.tavily.com). The UI shows **Configured**, never the saved key. Enter a new key and save to replace it; an empty input does not erase it. +2. Turn **Web search** on or off. This retains the saved key and existing custom limits. Saving other settings also retains the search settings and credentials. +3. Optionally click **Test connection** to test the **saved** key. Unsaved input is not tested. This explicit action works even with search disabled and does not enable search or authorize any task. +4. **Clear key** removes only the Tavily credential. It does not change the search switch, custom limits, model credentials, or key-independent public webpage reading. + +Changes apply to subsequent new runs (including the next turn in an existing design). Running tasks retain their starting configuration and credential snapshot; clearing or replacing a key does not implicitly change an in-flight task. Stop an active task if it must no longer use its previously authorized access. Saving/enabling never grants blanket networking permission: each new run still uses the existing **Allow this run / Deny** dialog. + +Search queries are sent to Tavily; webpage requests go directly to the requested public host. Never paste a key into chat or a design workspace. Key input is transient UI state and is cleared after saving; saved credentials are resolved only in the main process. Get/save/test IPC responses contain only configuration status or bounded diagnostic codes, never credentials, request headers, provider response bodies, or raw errors. No new credential store is introduced: the existing OS-backed `safe:` storage (or `plain:` fallback when unavailable) and active app configuration are reused. + +### Test connection + +The button sends exactly one bounded `POST https://api.tavily.com/search` request with Bearer authentication, fixed query `Tavily`, `max_results: 1`, `search_depth: "basic"`, `auto_parameters: false`, and no generated answer or raw content. It is not run on open, save, toggle, or clear. It consumes **1 Tavily API credit** for a successful basic search, but does **not** consume a task's call budget, grant/bypass task authorization, or save research/chat/workspace records. There is no automatic retry or fallback. + +The [official Search API reference](https://docs.tavily.com/documentation/api-reference/endpoint/search) documents this request, credit cost, and error statuses (also available as [Markdown/OpenAPI](https://docs.tavily.com/documentation/api-reference/endpoint/search.md)): + +- `401`: missing/invalid API key (the app checks for missing local credentials before requesting). +- `429`: rate limited; wait before retrying. +- `432` / `433`: key/plan usage limit or pay-as-you-go limit exceeded; check the Tavily dashboard. +- Local deadline expiry: timeout, not evidence that the key is invalid. +- DNS/TLS/connection failures: network failure, without guessing the specific cause. +- Server errors or other HTTP statuses: service/unknown response with the HTTP status, not a claim of invalid credentials. Malformed successful replies are reported as unexpected responses, not success. + +Unreadable encrypted credentials are shown as needing replacement, without exposing decryption errors. A successful test confirms that single request only, not future quota or service availability. + +### Existing manual configuration + +Existing supported manual configuration and custom storage locations remain compatible. If configuring manually while the app is closed, the active `config.toml` (normally `~/.config/open-codesign/config.toml`; respects `XDG_CONFIG_HOME` and custom storage locations) supports: ```toml [webSearch] @@ -17,13 +45,13 @@ maxChars = 10000 ciphertext = "plain:YOUR_TAVILY_API_KEY" ``` -Restart the app. Do not paste the key into chat or a design workspace. The `ciphertext` name is the existing credential-storage format; `plain:` is its supported human-readable local form. Existing `safe:` credentials are also handled by the main-process credential resolver. Provider settings and OAuth changes retain the search configuration. +The `ciphertext` name is the existing credential-storage format; `plain:` is its supported human-readable local form. Existing `safe:` and readable legacy encrypted credentials are also handled by the main-process resolver. Provider settings and OAuth changes retain the search configuration. Manual edits made outside the app are read on launch, not watched live; use Settings for immediate updates to new runs. If an older build fails to start with `Failed to decrypt a legacy API key`, an entry without `plain:` or `safe:` is being interpreted as legacy encrypted data. Check the entry you added: a newly copied Tavily key must be `ciphertext = "plain:tvly-..."`, not just `ciphertext = "tvly-..."`. Do not prefix existing encrypted values with `plain:`; replace them with a freshly copied key if needed. The log alone does not identify which entry failed. Credential migration now preserves unreadable entries and logs their provider ID without aborting startup; that credential still needs repair before use. Search is disabled by default. Its first network call asks through the existing structured-question dialog for this run's bounded public-web access: choose **Allow this run** or **Deny**. Denial or cancellation prevents the request. Permission is not carried into later runs; this v1 does not persist a network allowlist. A missing Tavily key is an explicit search error, not an empty result or simulated success. Public webpage reading does not require a Tavily key. Search queries are sent to Tavily; page requests go directly to the requested public host. Credentials are never tool arguments or results. -If a tool reports **Web access is disabled**, the run loaded a missing or false `webSearch.enabled`; this is not an HTTP error from the target website. A Tavily key alone does not enable networking. Fully quit the app, check the active config directory shown in Settings (not a workspace config), add or update the top-level `[webSearch]` section above, then restart and start a new turn. Older builds could drop this section when saving provider/model, import, image or design-system settings; those save paths now preserve it, including an explicit `false`. If an older build already removed the section, it needs to be added again. +If a tool reports **Web access is disabled**, open **Settings → Web Search**, enable it, then start a new turn. If Tavily credentials are missing or unreadable, save/replace the key there and start a new turn. These errors include Settings guidance; they are not HTTP errors from a target website. A Tavily key alone does not enable networking. Older builds could drop the search section when saving provider/model, import, image or design-system settings; current save paths preserve it, including an explicit `false`. If it was previously lost, enable it again in Settings. Settings limits: `maxCalls` 1–50 (search and fetch combined per run, including failed network attempts); `timeoutMs` 1,000–60,000; `maxChars` 1,000–12,000 (per fetched body). Search allows 1–5 results and at most 2,000 snippet characters per result. Each HTTP response is limited to 1 MiB, and page fetches allow at most five redirects. Records are limited to 8 MiB per workspace. @@ -90,6 +118,9 @@ Existing lightweight HTML string helpers are not a full HTML5 parser; using a br Focused checks: ```sh -pnpm --filter @open-codesign/desktop exec vitest run src/main/web-research-network.test.ts src/main/web-research.test.ts src/main/exporter-ipc.test.ts +pnpm --filter @open-codesign/shared exec vitest run src/config.test.ts src/web-search-settings.test.ts +pnpm --filter @open-codesign/desktop exec vitest run src/main/web-search-settings.test.ts src/main/onboarding-ipc.test.ts src/main/web-research-network.test.ts src/main/web-research-run.test.ts src/main/keychain.test.ts src/main/onboarding/config-cache.test.ts +pnpm --filter @open-codesign/desktop exec vitest run src/renderer/src/components/settings/WebSearchTab.test.tsx src/renderer/src/components/Settings.test.ts src/preload/web-search.test.ts +pnpm --filter @open-codesign/desktop exec vitest run src/main/web-research.test.ts src/main/exporter-ipc.test.ts pnpm --filter @open-codesign/core exec vitest run src/tool-manifest.test.ts src/agent.test.ts ``` diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 5e57b1c1f..cb976e630 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -90,7 +90,7 @@ import { import { registerSourceEditBusyCheck } from '../source-edits-ipc'; import { withTlsBypass } from '../tls-override'; import { createResearchHost, createWebResearchAuthorization } from '../web-research'; -import { createWebResearchNetwork } from '../web-research-network'; +import { createWebResearchRun } from '../web-research-run'; import { withStableWorkspacePath } from '../workspace-path-lock'; import { listWorkspaceFilesAt, readWorkspaceFilesAt } from '../workspace-reader'; import { finalAssistantTextForTurn } from './assistant-text'; @@ -690,6 +690,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe designId: string, previousSource: string | null, workspaceRoot: string, + researchRun: ReturnType, attachmentsForRuntimeFs?: Parameters[0]['attachments'], memoryCallbacks?: { onAggressivePrune?: () => void; @@ -700,6 +701,8 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe publishEvent(event); }; const baseCtx = { designId, generationId: id } as const; + const cfg = getCachedConfig(); + const { settings: researchSettings, network } = researchRun; const toolStartedAt = new Map(); const templatesRoot = path_module.join(app.getPath('userData'), 'templates'); const currentWorkspaceRoot = () => requireWorkspaceRootForDesign(designId).workspaceRoot; @@ -721,32 +724,8 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe frames, designSkills, }); - const cfg = getCachedConfig(); - const researchSettings = cfg?.webSearch ?? { - enabled: false, - maxCalls: 12, - timeoutMs: 15000, - maxChars: 10000, - }; - // Keep credentials in this process and resolve only when a network tool is used. - let network: ReturnType | undefined; - const getResearchNetwork = () => { - if (!network) { - const stored = cfg?.secrets['tavily']; - network = createWebResearchNetwork({ - ...researchSettings, - ...(stored && researchSettings.enabled - ? { apiKey: decryptSecret(stored.ciphertext) } - : {}), - }); - } - return network; - }; const research = createResearchHost({ - network: { - search: (query, count, signal) => getResearchNetwork().search(query, count, signal), - fetch: (url, signal) => getResearchNetwork().fetch(url, signal), - }, + network, inWorkspace: (fn) => withStableWorkspacePath(designId, () => fn(currentWorkspaceRoot())), authorize: createWebResearchAuthorization(researchSettings, (questions, signal) => requestAsk(id, questions, () => getMainWindow(), { @@ -1116,6 +1095,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe 'CONFIG_MISSING', ); } + const researchRun = createWebResearchRun(cfg, decryptSecret); const active = resolveActiveModel(cfg, payload.model); const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); @@ -1357,6 +1337,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe designId, payload.previousSource ?? null, workspaceRoot, + researchRun, promptContext.attachments, { onAggressivePrune: () => { @@ -1626,6 +1607,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe 'CONFIG_MISSING', ); } + const researchRun = createWebResearchRun(cfg, decryptSecret); // Inline-comment edits don't need to be tied to whatever provider was // pinned in the original generate; resolve fresh against the canonical // active provider so a switch in Settings takes effect immediately. @@ -1714,6 +1696,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe payload.designId, payload.artifactSource, workspaceRoot, + researchRun, promptContext.attachments, ), ); diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index fba0dcec6..02453a645 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -132,6 +132,44 @@ vi.mock('@open-codesign/providers', () => ({ })); describe('registerOnboardingIpc — channel versioning', () => { + it('routes search settings through safe versioned get/save/test handlers', async () => { + const { hydrateConfig, WEB_SEARCH_SETTINGS_CHANNELS } = await import('@open-codesign/shared'); + const { setCachedConfig } = await import('./onboarding/config-cache'); + const network = await import('./web-research-network'); + const probe = vi.spyOn(network, 'testTavilyConnection').mockResolvedValue({ status: 'ok' }); + setCachedConfig( + hydrateConfig({ + version: 3, + activeProvider: '', + activeModel: '', + providers: {}, + secrets: {}, + }), + ); + await registerIpcForTest(); + try { + const get = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.get); + const save = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.save); + const test = handlers.get(WEB_SEARCH_SETTINGS_CHANNELS.test); + expect(get).toBeDefined(); + expect(save).toBeDefined(); + expect(test).toBeDefined(); + expect(await get?.({})).toEqual({ enabled: false, hasKey: false }); + expect(await save?.({}, { apiKey: 'tvly-ipc-fixture' })).toEqual({ + enabled: false, + hasKey: true, + }); + expect(await save?.({}, { enabled: true })).toEqual({ enabled: true, hasKey: true }); + expect(probe).not.toHaveBeenCalled(); + expect(await test?.({})).toEqual({ status: 'ok' }); + expect(probe).toHaveBeenCalledExactlyOnceWith('tvly-ipc-fixture', 15000); + expect(await save?.({}, { clearKey: true })).toEqual({ enabled: true, hasKey: false }); + expect(await test?.({})).toEqual({ status: 'missing-key' }); + expect(probe).toHaveBeenCalledOnce(); + } finally { + probe.mockRestore(); + } + }); it('registers settings:v1:list-providers without the unversioned settings:list-providers shim', async () => { await registerIpcForTest(); diff --git a/apps/desktop/src/main/onboarding/register.ts b/apps/desktop/src/main/onboarding/register.ts index c7e4b1cfe..870029816 100644 --- a/apps/desktop/src/main/onboarding/register.ts +++ b/apps/desktop/src/main/onboarding/register.ts @@ -4,6 +4,7 @@ import { ERROR_CODES, type ExternalConfigsDetection, type OnboardingState, + WEB_SEARCH_SETTINGS_CHANNELS, } from '@open-codesign/shared'; import { ipcMain } from '../electron-runtime'; import { readClaudeCodeSettings } from '../imports/claude-code-config'; @@ -13,6 +14,11 @@ import { readOpencodeConfig } from '../imports/opencode-config'; import { getLogger } from '../logger'; import type { ProviderRow } from '../provider-settings'; import type { AppPaths } from '../storage-settings'; +import { + getWebSearchSettings, + saveWebSearchSettings, + testWebSearchSettings, +} from '../web-search-settings'; import { getCachedConfig, toState } from './config-cache'; import { runImportClaudeCode, @@ -47,6 +53,12 @@ const logger = getLogger('settings-ipc'); // silently" background. export function registerOnboardingIpc(): void { + ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.get, () => getWebSearchSettings()); + ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.save, (_event, raw: unknown) => + saveWebSearchSettings(raw), + ); + ipcMain.handle(WEB_SEARCH_SETTINGS_CHANNELS.test, () => testWebSearchSettings()); + ipcMain.handle('onboarding:get-state', (): OnboardingState => toState(getCachedConfig())); ipcMain.handle('onboarding:validate-key', async (_e, raw: unknown): Promise => { diff --git a/apps/desktop/src/main/web-research-network.test.ts b/apps/desktop/src/main/web-research-network.test.ts index 3e3de2e95..589b14b41 100644 --- a/apps/desktop/src/main/web-research-network.test.ts +++ b/apps/desktop/src/main/web-research-network.test.ts @@ -8,6 +8,7 @@ import { publicWebUrl, readableHtml, requestPublicUrl, + testTavilyConnection, } from './web-research-network'; const settings = { @@ -267,6 +268,153 @@ it('aborts the in-flight request, not only a wrapper promise', async () => { expect(destroy).toHaveBeenCalledOnce(); }); +describe('Tavily connection probe', () => { + it.each([ + [200, 'ok'], + [401, 'invalid-key'], + [429, 'rate-limit'], + [432, 'quota'], + [433, 'quota'], + [500, 'service-error'], + [201, 'unexpected-response'], + [204, 'unexpected-response'], + [301, 'unexpected-response'], + [302, 'unexpected-response'], + [307, 'unexpected-response'], + [308, 'unexpected-response'], + [400, 'unexpected-response'], + [402, 'unexpected-response'], + [403, 'unexpected-response'], + [404, 'unexpected-response'], + [408, 'unexpected-response'], + [502, 'unexpected-response'], + [503, 'unexpected-response'], + [504, 'unexpected-response'], + ] as const)('maps HTTP %s to %s without retries or redirects', async (httpStatus, status) => { + const fake = transport([ + { + status: httpStatus, + headers: { location: 'https://other.example/steal' }, + body: JSON.stringify({ results: [], error: settings.apiKey }), + }, + ]); + expect(await testTavilyConnection(settings.apiKey, 1000, fake)).toEqual({ status, httpStatus }); + expect(fake.seen).toHaveLength(1); + expect(fake.resolve).toHaveBeenCalledOnce(); + }); + it('sends one fixed minimal basic search using Bearer auth and the bounded transport', async () => { + const fake = transport([{ body: '{"results":[]}' }]); + expect(await testTavilyConnection(settings.apiKey, 1000, fake)).toEqual({ + status: 'ok', + httpStatus: 200, + }); + expect(fake.seen).toHaveLength(1); + const sent = fake.seen[0]; + expect(sent?.url.href).toBe('https://api.tavily.com/search'); + expect(sent?.options.method).toBe('POST'); + expect(sent?.options.agent).toBe(false); + expect(sent?.options.headers).toMatchObject({ + Authorization: `Bearer ${settings.apiKey}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'identity', + }); + expect(JSON.parse(sent?.body ?? '{}')).toEqual({ + query: 'Tavily', + max_results: 1, + search_depth: 'basic', + include_answer: false, + include_raw_content: false, + auto_parameters: false, + }); + expect(sent?.body).not.toContain(settings.apiKey); + expect(fake.resolve).toHaveBeenCalledWith('api.tavily.com'); + const lookup = vi.fn(); + sent?.options.lookup?.('api.tavily.com', { all: false }, lookup); + expect(lookup).toHaveBeenCalledWith(null, '93.184.216.34', 4); + }); + it.each([ + 'secret-never-log', + 'not JSON secret-never-log', + '{"results":', + 'null', + '[]', + '{}', + '{"results":null}', + '{"results":{}}', + '{"results":"secret-never-log"}', + ])('rejects malformed successful replies without leaking content: %s', async (body) => { + const fake = transport([{ body }]); + expect(await testTavilyConnection(settings.apiKey, 1000, fake)).toEqual({ + status: 'unexpected-response', + httpStatus: 200, + }); + expect(fake.seen).toHaveLength(1); + }); + it('redacts arbitrary transport errors, even errors claiming to be timeouts', async () => { + for (const message of [ + settings.apiKey, + `TimeoutError ${settings.apiKey}`, + `HTTP ${settings.apiKey}`, + ]) { + const fake = transport([]); + fake.resolve.mockRejectedValue(new Error(message)); + expect(await testTavilyConnection(settings.apiKey, 1000, fake)).toEqual({ + status: 'network-error', + }); + expect(fake.resolve).toHaveBeenCalledOnce(); + expect(fake.seen).toHaveLength(0); + } + }); + it('enforces public DNS and the 1 MiB response bound', async () => { + const privateDns = transport([]); + privateDns.resolve.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); + expect(await testTavilyConnection(settings.apiKey, 1000, privateDns)).toEqual({ + status: 'network-error', + }); + expect(privateDns.seen).toHaveLength(0); + const huge = transport([{ body: settings.apiKey.repeat(100000) }]); + expect(await testTavilyConnection(settings.apiKey, 1000, huge)).toEqual({ + status: 'network-error', + }); + expect(huge.seen).toHaveLength(1); + }); + it('times out stalled DNS and aborts the single in-flight request', async () => { + const resolve = vi.fn(() => new Promise(() => {})); + expect(await testTavilyConnection(settings.apiKey, 10, { resolve })).toEqual({ + status: 'timeout', + }); + expect(resolve).toHaveBeenCalledOnce(); + let signal: AbortSignal | undefined; + const send = vi.fn((_url: URL, options: RequestOptions) => { + signal = options.signal; + const req = new EventEmitter() as ClientRequest; + req.write = (() => true) as ClientRequest['write']; + req.end = (() => req) as ClientRequest['end']; + options.signal?.addEventListener( + 'abort', + () => req.emit('error', new Error(settings.apiKey)), + { once: true }, + ); + return req; + }); + expect( + await testTavilyConnection(settings.apiKey, 10, { + resolve: async () => [{ address: '8.8.8.8', family: 4 }], + request: send as typeof request, + }), + ).toEqual({ status: 'timeout' }); + expect(signal?.aborted).toBe(true); + expect(send).toHaveBeenCalledOnce(); + }); + it('does not consume the active research network budget', async () => { + const fake = transport([{ body: '{"results":[]}' }, { body: 'page' }]); + const network = createWebResearchNetwork({ ...settings, maxCalls: 1 }, fake); + await testTavilyConnection(settings.apiKey, 1000, fake); + await expect(network.fetch('https://example.com')).resolves.toMatchObject({ text: 'page' }); + await expect(network.fetch('https://example.com')).rejects.toThrow(/budget/); + }); +}); + describe('HTML5 text extraction (not an HTML sanitizer)', () => { it('handles quoted angle brackets and decodes character references once', async () => { expect( diff --git a/apps/desktop/src/main/web-research-network.ts b/apps/desktop/src/main/web-research-network.ts index a8e354ced..3788134d4 100644 --- a/apps/desktop/src/main/web-research-network.ts +++ b/apps/desktop/src/main/web-research-network.ts @@ -304,10 +304,83 @@ export async function readableHtml(html: string): Promise<{ text: string; title: }; } +function tavilyRequest(apiKey: string, query: string, count: number, signal: AbortSignal) { + return { + signal, + authorization: `Bearer ${apiKey}`, + body: JSON.stringify({ + query, + max_results: count, + search_depth: 'basic', + include_answer: false, + include_raw_content: false, + auto_parameters: false, + }), + }; +} + +export interface TavilyConnectionResult { + status: + | 'ok' + | 'invalid-key' + | 'quota' + | 'rate-limit' + | 'timeout' + | 'network-error' + | 'service-error' + | 'unexpected-response'; + httpStatus?: number; +} + +export async function testTavilyConnection( + apiKey: string, + timeoutMs: number, + deps: NetworkDependencies = {}, +): Promise { + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), timeoutMs); + try { + // A basic search costs one credit. Never follow redirects with this credential. + const response = await requestPublicUrl( + new URL('https://api.tavily.com/search'), + tavilyRequest(apiKey, 'Tavily', 1, timeout.signal), + deps, + ); + const httpStatus = response.status; + switch (httpStatus) { + case 200: + try { + const body: unknown = JSON.parse(response.body); + if (body && typeof body === 'object' && 'results' in body && Array.isArray(body.results)) + return { status: 'ok', httpStatus }; + } catch { + // Upstream bodies and parser errors may contain the credential. + } + return { status: 'unexpected-response', httpStatus }; + case 401: + return { status: 'invalid-key', httpStatus }; + case 429: + return { status: 'rate-limit', httpStatus }; + case 432: + case 433: + return { status: 'quota', httpStatus }; + case 500: + return { status: 'service-error', httpStatus }; + default: + return { status: 'unexpected-response', httpStatus }; + } + } catch { + return { status: timeout.signal.aborted ? 'timeout' : 'network-error' }; + } finally { + clearTimeout(timer); + } +} + export function createWebResearchNetwork( options: { enabled: boolean; apiKey?: string; + getApiKey?: () => string | undefined; timeoutMs: number; maxChars: number; maxCalls: number; @@ -322,7 +395,7 @@ export function createWebResearchNetwork( signal?.throwIfAborted(); if (!options.enabled) throw new Error( - 'Web access is disabled for web_search and web_fetch. Set enabled = true in the top-level [webSearch] section of the active config.toml, then fully quit and restart Open CoDesign. A Tavily key alone does not enable web access.', + 'Web access is disabled for web_search and web_fetch. Enable it in Settings > Web Search, then start a new turn; no app restart is needed. A Tavily key alone does not enable web access.', ); if (calls >= options.maxCalls) throw new Error( @@ -355,11 +428,12 @@ export function createWebResearchNetwork( async search(query, count, signal) { if (!options.enabled) throw new Error( - 'Web access is disabled for web_search and web_fetch. Set enabled = true in the top-level [webSearch] section of the active config.toml, then fully quit and restart Open CoDesign. A Tavily key alone does not enable web access.', + 'Web access is disabled for web_search and web_fetch. Enable it in Settings > Web Search, then start a new turn; no app restart is needed. A Tavily key alone does not enable web access.', ); - if (!options.apiKey) + const apiKey = options.getApiKey ? options.getApiKey() : options.apiKey; + if (!apiKey) throw new Error( - 'Tavily credentials are not configured. Set [secrets.tavily] ciphertext = "plain:YOUR_TAVILY_KEY" in the local config.toml and restart. Never paste keys into chat.', + 'Tavily credentials are not configured. Add a key in Settings > Web Search, then start a new turn; no app restart is needed. Never paste keys into chat.', ); if ( !query.trim() || @@ -372,17 +446,7 @@ export function createWebResearchNetwork( return run(async (combined) => { const response = await requestPublicUrl( new URL('https://api.tavily.com/search'), - { - signal: combined, - authorization: `Bearer ${options.apiKey}`, - body: JSON.stringify({ - query, - max_results: count, - search_depth: 'basic', - include_answer: false, - include_raw_content: false, - }), - }, + tavilyRequest(apiKey, query, count, combined), deps, ); if (response.status < 200 || response.status >= 300) diff --git a/apps/desktop/src/main/web-research-run.test.ts b/apps/desktop/src/main/web-research-run.test.ts new file mode 100644 index 000000000..3e622b71c --- /dev/null +++ b/apps/desktop/src/main/web-research-run.test.ts @@ -0,0 +1,204 @@ +import { EventEmitter } from 'node:events'; +import type { ClientRequest, IncomingMessage, RequestOptions, request } from 'node:http'; +import type { Config } from '@open-codesign/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { createWebResearchAuthorization } from './web-research'; +import { createWebResearchRun } from './web-research-run'; + +function transport() { + const seen: RequestOptions[] = []; + const send = (( + _url: URL, + options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + seen.push(options); + const req = new EventEmitter() as ClientRequest; + req.write = (() => true) as ClientRequest['write']; + req.end = (() => { + queueMicrotask(() => { + const response = new EventEmitter() as IncomingMessage; + response.statusCode = 200; + response.headers = { 'content-type': 'text/plain' }; + callback(response); + response.emit( + 'data', + Buffer.from(options.method === 'POST' ? '{"results":[]}' : 'x'.repeat(4000)), + ); + response.emit('end'); + }); + return req; + }) as ClientRequest['end']; + return req; + }) as typeof request; + return { seen, request: send, resolve: async () => [{ address: '8.8.8.8', family: 4 }] }; +} + +function config(): Pick { + return { + webSearch: { enabled: true, maxCalls: 2, timeoutMs: 1000, maxChars: 1000 }, + secrets: { tavily: { ciphertext: 'original-key' } }, + }; +} + +describe('per-generation web research snapshot', () => { + it('copies enabled, limits and encrypted key before first use; later runs use the latest config', async () => { + const cached = config(); + const getCachedConfig = vi.fn(() => cached); + const decrypt = vi.fn((value: string) => value); + const fake = transport(); + const startRun = () => createWebResearchRun(getCachedConfig(), decrypt, fake); + const active = startRun(); + expect(decrypt).not.toHaveBeenCalled(); + // Even an in-place cache edit must not alter the active run's snapshot. + Object.assign(cached.webSearch ?? {}, { + enabled: false, + maxCalls: 3, + timeoutMs: 2000, + maxChars: 2000, + }); + if (cached.secrets['tavily']) cached.secrets['tavily'].ciphertext = 'replacement-key'; + const disabled = startRun(); + expect(active.settings).toEqual({ + enabled: true, + maxCalls: 2, + timeoutMs: 1000, + maxChars: 1000, + }); + expect(disabled.settings).toEqual({ + enabled: false, + maxCalls: 3, + timeoutMs: 2000, + maxChars: 2000, + }); + await expect(disabled.network.search('query', 1)).rejects.toThrow(/Settings > Web Search/); + await expect(disabled.network.fetch('https://example.com')).rejects.toThrow(/disabled/); + expect(decrypt).not.toHaveBeenCalled(); + await active.network.search('query', 1); + expect(fake.seen[0]?.headers).toMatchObject({ Authorization: 'Bearer original-key' }); + expect((await active.network.fetch('https://example.com')).text).toHaveLength(1000); + await expect(active.network.search('query', 1)).rejects.toThrow(/budget/); + if (cached.webSearch) cached.webSearch.enabled = true; + const enabled = startRun(); + expect(enabled.settings).toEqual({ + enabled: true, + maxCalls: 3, + timeoutMs: 2000, + maxChars: 2000, + }); + await enabled.network.search('query', 1); + expect(fake.seen[2]?.headers).toMatchObject({ Authorization: 'Bearer replacement-key' }); + expect((await enabled.network.fetch('https://example.com')).text).toHaveLength(2000); + await enabled.network.fetch('https://example.com'); + await expect(enabled.network.fetch('https://example.com')).rejects.toThrow(/budget/); + await expect(disabled.network.fetch('https://example.com')).rejects.toThrow(/disabled/); + expect(getCachedConfig).toHaveBeenCalledTimes(3); + expect(decrypt.mock.calls).toEqual([['original-key'], ['replacement-key']]); + }); + it('uses the captured timeout for active requests and the updated timeout in new runs', async () => { + vi.useFakeTimers(); + try { + const cached = config(); + const deps = { resolve: () => new Promise(() => {}) }; + const active = createWebResearchRun(cached, (value) => value, deps); + if (cached.webSearch) cached.webSearch.timeoutMs = 2000; + const next = createWebResearchRun(cached, (value) => value, deps); + const activeRequest = expect(active.network.fetch('https://example.com')).rejects.toThrow( + /timed out/, + ); + let nextTimedOut = false; + const nextRequest = expect(next.network.fetch('https://example.com')) + .rejects.toThrow(/timed out/) + .then(() => { + nextTimedOut = true; + }); + await vi.advanceTimersByTimeAsync(1000); + await activeRequest; + expect(nextTimedOut).toBe(false); + await vi.advanceTimersByTimeAsync(1000); + await nextRequest; + expect(nextTimedOut).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('keeps direct fetch usable when a stored Tavily credential cannot be decrypted', async () => { + const decrypt = vi.fn(() => { + throw new Error('secret-key-that-must-not-escape'); + }); + const fake = transport(); + const run = createWebResearchRun(config(), decrypt, fake); + expect(decrypt).not.toHaveBeenCalled(); + await expect(run.network.fetch('https://example.com')).resolves.toMatchObject({ + contentType: 'text/plain', + }); + expect(decrypt).not.toHaveBeenCalled(); + await expect(run.network.search('query', 1)).rejects.toThrow( + 'Tavily credentials could not be read. Replace the key in Settings > Web Search, then start a new turn; no app restart is needed. Never paste keys into chat.', + ); + await expect(run.network.search('query', 1)).rejects.not.toThrow( + /secret-key-that-must-not-escape/, + ); + await expect(run.network.fetch('https://example.com')).resolves.toMatchObject({ + contentType: 'text/plain', + }); + expect(decrypt).toHaveBeenCalledOnce(); + expect(fake.seen).toHaveLength(2); + for (const sent of fake.seen) expect(sent.headers).not.toHaveProperty('Authorization'); + }); + it('clearing a key affects the next run without disabling fetch or changing an active key', async () => { + const cached = config(); + const decrypt = vi.fn((value: string) => value); + const fake = transport(); + const active = createWebResearchRun(cached, decrypt, fake); + delete cached.secrets['tavily']; + const cleared = createWebResearchRun(cached, decrypt, fake); + await expect(cleared.network.search('query', 1)).rejects.toThrow(/Settings > Web Search/); + expect(decrypt).not.toHaveBeenCalled(); + await expect(cleared.network.fetch('https://example.com')).resolves.toMatchObject({ + contentType: 'text/plain', + }); + await active.network.search('query', 1); + expect(fake.seen[1]?.headers).toMatchObject({ Authorization: 'Bearer original-key' }); + cached.secrets['tavily'] = { ciphertext: 'new-key' }; + await active.network.search('query', 1); + expect(fake.seen[2]?.headers).toMatchObject({ Authorization: 'Bearer original-key' }); + expect(decrypt).toHaveBeenCalledOnce(); + await expect(cleared.network.search('query', 1)).rejects.toThrow(/not configured/); + }); + it('keeps authorization decisions and request limits scoped to each run', async () => { + const cached = config(); + const fake = transport(); + const active = createWebResearchRun(cached, (value) => value, fake); + const ask = vi.fn[1]>(async () => ({ + status: 'answered' as const, + answers: [{ questionId: 'web-research-permission', value: 'Allow this run' }], + })); + const authorize = createWebResearchAuthorization(active.settings, ask); + await authorize(); + if (cached.webSearch) cached.webSearch.maxCalls = 5; + const next = createWebResearchRun(cached, (value) => value, fake); + const authorizeNext = createWebResearchAuthorization(next.settings, ask); + await authorize(); + expect(ask).toHaveBeenCalledOnce(); + expect(ask.mock.calls[0]?.[0].questions[0]?.prompt).toContain('2 public'); + await authorizeNext(); + expect(ask).toHaveBeenCalledTimes(2); + expect(ask.mock.calls[1]?.[0].questions[0]?.prompt).toContain('5 public'); + expect(fake.seen).toHaveLength(0); + }); + it('uses safe disabled defaults without trying to decrypt absent config', async () => { + const decrypt = vi.fn((value: string) => value); + const run = createWebResearchRun(null, decrypt); + expect(run.settings).toEqual({ + enabled: false, + maxCalls: 12, + timeoutMs: 15000, + maxChars: 10000, + }); + await expect(run.network.fetch('https://example.com')).rejects.toThrow( + /new turn; no app restart/, + ); + expect(decrypt).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/web-research-run.ts b/apps/desktop/src/main/web-research-run.ts new file mode 100644 index 000000000..3db7aa208 --- /dev/null +++ b/apps/desktop/src/main/web-research-run.ts @@ -0,0 +1,42 @@ +import type { Config } from '@open-codesign/shared'; +import { createWebResearchNetwork, type NetworkDependencies } from './web-research-network'; + +export function createWebResearchRun( + config: Pick | null | undefined, + decrypt: (ciphertext: string) => string, + deps: NetworkDependencies = {}, +) { + // Copy values now: later settings edits must not change an active run's grant or key. + const settings = { + enabled: config?.webSearch?.enabled ?? false, + maxCalls: config?.webSearch?.maxCalls ?? 12, + timeoutMs: config?.webSearch?.timeoutMs ?? 15000, + maxChars: config?.webSearch?.maxChars ?? 10000, + }; + const ciphertext = config?.secrets['tavily']?.ciphertext; + let resolved = false; + let unreadable = false; + let apiKey: string | undefined; + const network = createWebResearchNetwork( + { + ...settings, + getApiKey: () => { + if (!resolved) { + resolved = true; + try { + apiKey = ciphertext ? decrypt(ciphertext) : undefined; + } catch { + unreadable = true; + } + } + if (unreadable) + throw new Error( + 'Tavily credentials could not be read. Replace the key in Settings > Web Search, then start a new turn; no app restart is needed. Never paste keys into chat.', + ); + return apiKey; + }, + }, + deps, + ); + return { settings, network }; +} diff --git a/apps/desktop/src/main/web-search-settings.test.ts b/apps/desktop/src/main/web-search-settings.test.ts new file mode 100644 index 000000000..f7fa6ed6f --- /dev/null +++ b/apps/desktop/src/main/web-search-settings.test.ts @@ -0,0 +1,213 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseConfigFlexible, toPersistedV3 } from '@open-codesign/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as configFile from './config'; +import { readConfigRedacted } from './diagnostics/redact'; +import { decryptSecret } from './keychain'; +import { getCachedConfig, loadConfigOnBoot, setCachedConfig } from './onboarding/config-cache'; +import { + getWebSearchSettings, + saveWebSearchSettings, + testWebSearchSettings, +} from './web-search-settings'; + +const mocks = vi.hoisted(() => ({ + configDir: '', + encrypted: false, + probe: vi.fn(), + log: vi.fn(), +})); +vi.mock('./storage-settings', () => ({ + getActiveStorageLocations: () => ({ configDir: mocks.configDir }), +})); +vi.mock('./electron-runtime', () => ({ + safeStorage: { + isEncryptionAvailable: () => mocks.encrypted, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`), + decryptString: (value: Buffer) => { + const text = value.toString(); + if (!text.startsWith('encrypted:')) throw new Error('private decrypt failure'); + return text.slice('encrypted:'.length); + }, + }, +})); +vi.mock('./logger', () => ({ + getLogger: () => ({ warn: mocks.log, info: mocks.log, error: mocks.log, debug: mocks.log }), +})); +vi.mock('./web-research-network', () => ({ testTavilyConnection: mocks.probe })); + +const key = 'tvly-settings-test-fixture'; +const limits = { enabled: true, maxCalls: 7, timeoutMs: 4000, maxChars: 3000 }; +function fixture(ciphertext = `plain:${key}`) { + return parseConfigFlexible({ + version: 3, + activeProvider: '', + activeModel: '', + providers: {}, + secrets: { tavily: { ciphertext }, other: { ciphertext: 'plain:other-fixture' } }, + webSearch: limits, + imageGeneration: { schemaVersion: 1, enabled: false }, + }); +} + +beforeEach(async () => { + mocks.configDir = await mkdtemp(join(tmpdir(), 'codesign-search-settings-')); + mocks.encrypted = false; + mocks.probe.mockReset().mockResolvedValue({ status: 'ok' }); + mocks.log.mockClear(); + setCachedConfig(fixture()); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await rm(mocks.configDir, { recursive: true, force: true }); +}); + +describe('web search settings persistence', () => { + it('reads only configured status, without decrypting or returning any credential', () => { + setCachedConfig(fixture('safe:unreadable-fixture')); + expect(getWebSearchSettings()).toEqual({ enabled: true, hasKey: true }); + expect(JSON.stringify(getWebSearchSettings())).not.toContain(key); + expect(mocks.probe).not.toHaveBeenCalled(); + }); + + it('preserves keys, limits and unrelated settings when toggling search', async () => { + const before = toPersistedV3(fixture()); + expect(await saveWebSearchSettings({ enabled: false })).toEqual({ + enabled: false, + hasKey: true, + }); + const stored = await configFile.readConfig(); + expect(stored && toPersistedV3(stored)).toEqual({ + ...before, + webSearch: { ...limits, enabled: false }, + }); + expect(getCachedConfig()?.webSearch?.enabled).toBe(false); + expect(mocks.probe).not.toHaveBeenCalled(); + }); + + it.each([ + false, + true, + ])('saves and replaces a key using existing encryption (encrypted=%s)', async (encrypted) => { + mocks.encrypted = encrypted; + for (const apiKey of ['tvly-first-fixture', 'tvly-replacement-fixture']) { + const state = await saveWebSearchSettings({ apiKey }); + expect(state).toEqual({ enabled: true, hasKey: true }); + const stored = await configFile.readConfig(); + const ref = stored?.secrets['tavily']; + expect(ref?.ciphertext).toMatch(encrypted ? /^safe:/ : /^plain:/); + expect(decryptSecret(ref?.ciphertext ?? '')).toBe(apiKey); + expect(JSON.stringify(state)).not.toContain(apiKey); + const diagnostic = await readConfigRedacted({ includePaths: true, includeUrls: true }); + expect(diagnostic).not.toContain(apiKey); + expect(diagnostic).not.toContain(ref?.ciphertext); + expect(diagnostic).toContain('***REDACTED***'); + expect(stored?.webSearch).toEqual(limits); + expect(stored?.secrets['other']).toEqual(fixture().secrets['other']); + } + expect(JSON.stringify(mocks.log.mock.calls)).not.toContain('tvly-'); + expect(mocks.probe).not.toHaveBeenCalled(); + }); + + it.each([ + true, + false, + ])('clears only Tavily and preserves the enabled=%s switch', async (enabled) => { + setCachedConfig( + parseConfigFlexible({ ...toPersistedV3(fixture()), webSearch: { ...limits, enabled } }), + ); + expect(await saveWebSearchSettings({ clearKey: true })).toEqual({ enabled, hasKey: false }); + const stored = await configFile.readConfig(); + expect(stored?.secrets['tavily']).toBeUndefined(); + expect(stored?.secrets['other']).toEqual(fixture().secrets['other']); + expect(stored?.webSearch).toEqual({ ...limits, enabled }); + expect(await readFile(configFile.configPath(), 'utf8')).not.toContain(key); + await saveWebSearchSettings({ enabled: !enabled }); + expect(getWebSearchSettings().hasKey).toBe(false); + }); + + it('preserves absent webSearch when only replacing or clearing a key', async () => { + const { webSearch: _settings, ...rest } = toPersistedV3(fixture()); + setCachedConfig(parseConfigFlexible(rest)); + await saveWebSearchSettings({ apiKey: 'tvly-new-fixture' }); + expect(getCachedConfig()?.webSearch).toBeUndefined(); + await saveWebSearchSettings({ clearKey: true }); + expect(getCachedConfig()?.webSearch).toBeUndefined(); + }); + + it('can configure before model onboarding without auto enabling search', async () => { + await loadConfigOnBoot(); + expect(getWebSearchSettings()).toEqual({ enabled: false, hasKey: false }); + await saveWebSearchSettings({ apiKey: key }); + expect(getWebSearchSettings()).toEqual({ enabled: false, hasKey: true }); + expect((await configFile.readConfig())?.activeProvider).toBe(''); + }); + + it.each([ + null, + { enabled: 'true' }, + { apiKey: '' }, + { apiKey: ' ' }, + { apiKey: `${key}\nunsafe` }, + { apiKey: key, clearKey: true }, + { unknown: key }, + ])('rejects invalid payloads with sanitized errors', async (input) => { + await expect(saveWebSearchSettings(input)).rejects.toMatchObject({ + code: 'IPC_BAD_INPUT', + message: 'Invalid web search settings.', + }); + expect(getCachedConfig()?.secrets['tavily']).toEqual(fixture().secrets['tavily']); + }); + + it('retains cache on persistence failure and does not leak low-level errors', async () => { + vi.spyOn(configFile, 'writeConfig').mockRejectedValueOnce(new Error(`failed ${key}`)); + await expect(saveWebSearchSettings({ clearKey: true })).rejects.toMatchObject({ + code: 'WEB_SEARCH_SAVE_FAILED', + message: 'Could not save web search settings.', + }); + expect(getWebSearchSettings().hasKey).toBe(true); + await saveWebSearchSettings({ enabled: false }); + expect(getWebSearchSettings()).toEqual({ enabled: false, hasKey: true }); + }); + + it('serializes overlapping search settings saves without losing keys or switch', async () => { + await Promise.all([ + saveWebSearchSettings({ apiKey: 'tvly-concurrent-fixture' }), + saveWebSearchSettings({ enabled: false }), + ]); + expect(decryptSecret(getCachedConfig()?.secrets['tavily']?.ciphertext ?? '')).toBe( + 'tvly-concurrent-fixture', + ); + expect(getWebSearchSettings()).toEqual({ enabled: false, hasKey: true }); + }); +}); + +describe('explicit saved-key testing', () => { + it('reports missing and unreadable credentials locally without making a request', async () => { + await saveWebSearchSettings({ clearKey: true }); + expect(await testWebSearchSettings()).toEqual({ status: 'missing-key' }); + setCachedConfig(fixture('safe:unreadable-fixture')); + expect(await testWebSearchSettings()).toEqual({ status: 'unreadable-key' }); + expect(mocks.probe).not.toHaveBeenCalled(); + }); + + it.each([ + 'plain', + 'safe', + 'legacy', + ])('supports %s credential format without modifying the configuration', async (format) => { + mocks.encrypted = true; + const encoded = Buffer.from(`encrypted:${key}`).toString('base64'); + const ciphertext = + format === 'plain' ? `plain:${key}` : format === 'safe' ? `safe:${encoded}` : encoded; + setCachedConfig(fixture(ciphertext)); + await saveWebSearchSettings({ enabled: false }); + const before = getCachedConfig(); + expect(await testWebSearchSettings()).toEqual({ status: 'ok' }); + expect(mocks.probe).toHaveBeenCalledExactlyOnceWith(key, limits.timeoutMs); + expect(getCachedConfig()).toBe(before); + expect(getWebSearchSettings().enabled).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/web-search-settings.ts b/apps/desktop/src/main/web-search-settings.ts new file mode 100644 index 000000000..1715a5a6f --- /dev/null +++ b/apps/desktop/src/main/web-search-settings.ts @@ -0,0 +1,86 @@ +import { + CodesignError, + type Config, + ERROR_CODES, + hydrateConfig, + SaveWebSearchSettingsInput, + toPersistedV3, + type WebSearchSettingsState, + type WebSearchTestResult, +} from '@open-codesign/shared'; +import { writeConfig } from './config'; +import { buildSecretRef, decryptSecret } from './keychain'; +import { getCachedConfig, setCachedConfig } from './onboarding/config-cache'; +import { testTavilyConnection } from './web-research-network'; + +export function getWebSearchSettings(): WebSearchSettingsState { + return settingsState(getCachedConfig()); +} + +function settingsState(config: Config | null): WebSearchSettingsState { + return { + enabled: config?.webSearch?.enabled ?? false, + hasKey: config?.secrets['tavily'] !== undefined, + }; +} + +let pendingSave: Promise = Promise.resolve(); + +export function saveWebSearchSettings(raw: unknown): Promise { + const parsed = SaveWebSearchSettingsInput.safeParse(raw); + if (!parsed.success) { + // Validation errors can include the input: never send them over IPC. + return Promise.reject( + new CodesignError('Invalid web search settings.', ERROR_CODES.IPC_BAD_INPUT), + ); + } + const input = parsed.data; + const save = pendingSave.then(async () => { + try { + const current = getCachedConfig(); + const config = current + ? toPersistedV3(current) + : { version: 3 as const, activeProvider: '', activeModel: '', providers: {}, secrets: {} }; + const secrets = { ...config.secrets }; + if (input.clearKey === true) delete secrets['tavily']; + else if (input.apiKey !== undefined) secrets['tavily'] = buildSecretRef(input.apiKey); + const next = hydrateConfig({ + ...config, + secrets, + ...(input.enabled !== undefined + ? { + webSearch: { + maxCalls: 12, + timeoutMs: 15000, + maxChars: 10000, + ...config.webSearch, + enabled: input.enabled, + }, + } + : {}), + }); + await writeConfig(next); + setCachedConfig(next); + return settingsState(next); + } catch { + // OS encryption and persistence errors must not expose the key or payload. + throw new CodesignError('Could not save web search settings.', 'WEB_SEARCH_SAVE_FAILED'); + } + }); + pendingSave = save.catch(() => undefined); + return save; +} + +export async function testWebSearchSettings(): Promise { + const config = getCachedConfig(); + const stored = config?.secrets['tavily']; + if (!stored) return { status: 'missing-key' }; + let apiKey: string; + try { + apiKey = decryptSecret(stored.ciphertext); + } catch { + return { status: 'unreadable-key' }; + } + // This is a user-triggered diagnostic, not a task tool or authorization grant. + return testTavilyConnection(apiKey, config?.webSearch?.timeoutMs ?? 15000); +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2a6e40577..7feedfce9 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -26,6 +26,7 @@ import type { ReportEventInput, ReportEventResult, ResourceStateV1, + SaveWebSearchSettingsInput, SelectedElement, SnapshotCreateInput, SourceEditApplyRequestV1, @@ -33,6 +34,8 @@ import type { SourceEditInspectRequestV1, SourceEditInspectResultV1, SupportedOnboardingProvider, + WebSearchSettingsState, + WebSearchTestResult, WireApi, } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; @@ -638,6 +641,12 @@ const api = { update: (patch: Partial) => ipcRenderer.invoke('preferences:v1:update', patch) as Promise, }, + webSearch: { + get: () => ipcRenderer.invoke('settings:v1:get-web-search') as Promise, + save: (input: SaveWebSearchSettingsInput) => + ipcRenderer.invoke('settings:v1:save-web-search', input) as Promise, + test: () => ipcRenderer.invoke('settings:v1:test-web-search') as Promise, + }, memory: { getUser: () => ipcRenderer.invoke('memory:v1:get-user') as Promise, updateUser: (content: string) => diff --git a/apps/desktop/src/preload/web-search.test.ts b/apps/desktop/src/preload/web-search.test.ts new file mode 100644 index 000000000..134971066 --- /dev/null +++ b/apps/desktop/src/preload/web-search.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CodesignApi } from './index'; + +const electron = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn<(name: string, api: CodesignApi) => void>(), + invoke: vi.fn(), +})); +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld: electron.exposeInMainWorld }, + ipcRenderer: electron, +})); +import './index'; + +const api = electron.exposeInMainWorld.mock.calls[0]?.[1]; +if (!api) throw new Error('Missing exposed API'); + +beforeEach(() => electron.invoke.mockReset()); + +describe('web search preload bridge', () => { + it('reads only the saved enabled/configured state', async () => { + const state = { enabled: false, hasKey: true }; + electron.invoke.mockResolvedValueOnce(state); + expect(await api.webSearch.get()).toEqual(state); + expect(electron.invoke).toHaveBeenCalledExactlyOnceWith('settings:v1:get-web-search'); + }); + + it('forwards separate enable, replace and clear patches without filling in absent keys', async () => { + for (const input of [{ enabled: false }, { apiKey: 'tvly-fixture' }, { clearKey: true }]) { + await api.webSearch.save(input); + expect(electron.invoke).toHaveBeenLastCalledWith('settings:v1:save-web-search', input); + } + }); + + it('tests with no arguments, so unsaved keys and task authorization cannot be supplied', async () => { + electron.invoke.mockResolvedValueOnce({ status: 'ok' }); + expect(await api.webSearch.test()).toEqual({ status: 'ok' }); + expect(electron.invoke).toHaveBeenCalledExactlyOnceWith('settings:v1:test-web-search'); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/Settings.test.ts b/apps/desktop/src/renderer/src/components/Settings.test.ts index 2483f6e07..b9fa41780 100644 --- a/apps/desktop/src/renderer/src/components/Settings.test.ts +++ b/apps/desktop/src/renderer/src/components/Settings.test.ts @@ -142,6 +142,7 @@ describe('settings navigation', () => { expect(SETTINGS_TABS.map((entry) => entry.id)).toEqual([ 'models', 'images', + 'webSearch', 'appearance', 'workspace', 'memory', @@ -152,6 +153,7 @@ describe('settings navigation', () => { it('opens deep links on their matching primary settings tab', () => { expect(primarySettingsTab('images')).toBe('images'); + expect(primarySettingsTab('webSearch')).toBe('webSearch'); expect(primarySettingsTab('memory')).toBe('memory'); expect(primarySettingsTab('diagnostics')).toBe('diagnostics'); expect(primarySettingsTab('storage')).toBe('workspace'); diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 874123888..ddd9a03c9 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -1,5 +1,5 @@ import { useT } from '@open-codesign/i18n'; -import { Activity, Brain, Cpu, FolderOpen, Image, Palette, Sliders } from 'lucide-react'; +import { Activity, Brain, Cpu, FolderOpen, Globe, Image, Palette, Sliders } from 'lucide-react'; import { useEffect, useState } from 'react'; import { type SettingsTab, useCodesignStore } from '../store'; import { AdvancedTab } from './settings/AdvancedTab'; @@ -9,6 +9,7 @@ import { ImageGenerationTab } from './settings/ImageGenerationTab'; import { MemoryTab } from './settings/MemoryTab'; import { ModelsTab } from './settings/ModelsTab'; import { StorageTab } from './settings/StorageTab'; +import { WebSearchTab } from './settings/WebSearchTab'; export { resolveTimeoutOptions, TIMEOUT_OPTION_SECONDS } from './settings/AdvancedTab'; // Re-exports so Settings.test.ts keeps its public entry points. The actual @@ -16,11 +17,20 @@ export { resolveTimeoutOptions, TIMEOUT_OPTION_SECONDS } from './settings/Advanc export { applyLocaleChange } from './settings/AppearanceTab'; export { computeModelOptions } from './settings/primitives'; -type Tab = 'models' | 'images' | 'appearance' | 'workspace' | 'memory' | 'diagnostics' | 'advanced'; +type Tab = + | 'models' + | 'images' + | 'webSearch' + | 'appearance' + | 'workspace' + | 'memory' + | 'diagnostics' + | 'advanced'; export const SETTINGS_TABS: ReadonlyArray<{ id: Tab; icon: typeof Cpu }> = [ { id: 'models', icon: Cpu }, { id: 'images', icon: Image }, + { id: 'webSearch', icon: Globe }, { id: 'appearance', icon: Palette }, { id: 'workspace', icon: FolderOpen }, { id: 'memory', icon: Brain }, @@ -30,6 +40,7 @@ export const SETTINGS_TABS: ReadonlyArray<{ id: Tab; icon: typeof Cpu }> = [ export function primarySettingsTab(tab: SettingsTab | null): Tab { if (tab === 'images') return 'images'; + if (tab === 'webSearch') return 'webSearch'; if (tab === 'appearance') return 'appearance'; if (tab === 'storage' || tab === 'workspace') return 'workspace'; if (tab === 'memory') return 'memory'; @@ -81,6 +92,7 @@ export function Settings() {
{tab === 'models' ? : null} {tab === 'images' ? : null} + {tab === 'webSearch' ? : null} {tab === 'appearance' ? : null} {tab === 'workspace' ? : null} {tab === 'memory' ? : null} diff --git a/apps/desktop/src/renderer/src/components/settings/WebSearchTab.test.tsx b/apps/desktop/src/renderer/src/components/settings/WebSearchTab.test.tsx new file mode 100644 index 000000000..79b9e920d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/WebSearchTab.test.tsx @@ -0,0 +1,266 @@ +import { readFileSync } from 'node:fs'; +import { WebSearchTestResult } from '@open-codesign/shared'; +import { isValidElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WEB_SEARCH_TEST_MESSAGES, WebSearchTab } from './WebSearchTab'; + +const hooks = vi.hoisted(() => ({ + state: [] as unknown[], + refs: [] as { current: unknown }[], + stateIndex: 0, + refIndex: 0, + effects: [] as (() => undefined | (() => void))[], +})); +vi.mock('@open-codesign/i18n', () => ({ useT: () => (key: string) => key })); +vi.mock('react', async () => ({ + ...(await vi.importActual('react')), + useState: (initial: unknown) => { + const index = hooks.stateIndex++; + if (!(index in hooks.state)) hooks.state[index] = initial; + return [ + hooks.state[index], + (next: unknown) => { + hooks.state[index] = next; + }, + ]; + }, + useRef: (initial: unknown) => { + const index = hooks.refIndex++; + hooks.refs[index] ??= { current: initial }; + return hooks.refs[index]; + }, + useEffect: (effect: () => undefined | (() => void)) => hooks.effects.push(effect), +})); + +const api = { + get: vi.fn(), + save: vi.fn(), + test: vi.fn(), +}; +const openExternal = vi.fn(); +function render() { + hooks.stateIndex = 0; + hooks.refIndex = 0; + return WebSearchTab(); +} +type Props = { + children?: ReactNode; + type?: string; + role?: string; + onClick?: () => Promise | void; + onChange?: (event: { target: { checked: boolean } }) => void; + onSubmit?: (event: { preventDefault: () => void; currentTarget: HTMLFormElement }) => void; + disabled?: boolean; +}; +function find(node: ReactNode, match: (props: Props) => boolean): Props { + if (Array.isArray(node)) { + for (const child of node) { + try { + return find(child, match); + } catch { + /* try the next sibling */ + } + } + } else if (isValidElement(node)) { + if (match(node.props)) return node.props; + return find(node.props.children, match); + } + throw new Error('Element not found'); +} +async function mount() { + render(); + const cleanup = hooks.effects[0]?.(); + await Promise.resolve(); + return cleanup; +} +function button(label: string) { + return find(render(), (props) => props.children === label); +} + +beforeEach(() => { + hooks.state = []; + hooks.refs = []; + hooks.effects = []; + vi.resetAllMocks(); + api.get.mockResolvedValue({ enabled: false, hasKey: true }); + api.save.mockResolvedValue({ enabled: false, hasKey: true }); + api.test.mockResolvedValue({ status: 'ok' }); + vi.stubGlobal('window', { codesign: { webSearch: api, openExternal } }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('Tavily Settings UI', () => { + it('loads only state and never automatically tests on mount or after saving', async () => { + await mount(); + expect(api.get).toHaveBeenCalledExactlyOnceWith(); + expect(api.test).not.toHaveBeenCalled(); + find(render(), (props) => props.role === 'switch').onChange?.({ target: { checked: true } }); + await Promise.resolve(); + expect(api.save).toHaveBeenCalledExactlyOnceWith({ enabled: true }); + expect(api.test).not.toHaveBeenCalled(); + }); + + it('shows a blank password field and configured status, not a saved key or mask', async () => { + await mount(); + const html = renderToStaticMarkup(render()); + expect(html).toContain('type="password"'); + expect(html).toContain('autoComplete="off"'); + expect(html).toContain('value=""'); + expect(html).toContain('settings.webSearch.configured'); + expect(html).toContain('settings.webSearch.saveReplacement'); + expect(html).toContain('settings.webSearch.testHint'); + expect(html).not.toContain('maskedKey'); + }); + + it('tests only the saved key by explicit click even with an unsaved replacement', async () => { + await mount(); + hooks.state[1] = 'unsaved-secret'; + const test = find( + render(), + (props) => + props.onClick !== undefined && + Array.isArray(props.children) && + props.children.includes('settings.webSearch.test'), + ); + await test.onClick?.(); + expect(api.test).toHaveBeenCalledExactlyOnceWith(); + expect(api.save).not.toHaveBeenCalled(); + expect(hooks.state[3]).toEqual({ key: WEB_SEARCH_TEST_MESSAGES.ok, error: false }); + }); + + it('saves replacement separately and clears the input even when saving fails', async () => { + await mount(); + hooks.state[1] = 'replacement-fixture'; + vi.stubGlobal( + 'FormData', + class { + get() { + return hooks.state[1]; + } + }, + ); + api.save.mockRejectedValueOnce(new Error('secret upstream response')); + find(render(), (props) => props.onSubmit !== undefined).onSubmit?.({ + preventDefault: vi.fn(), + currentTarget: {} as HTMLFormElement, + }); + await Promise.resolve(); + expect(api.save).toHaveBeenCalledExactlyOnceWith({ apiKey: 'replacement-fixture' }); + expect(hooks.state[1]).toBe(''); + expect(hooks.state[3]).toEqual({ key: 'settings.webSearch.saveFailed', error: true }); + }); + + it('requires confirmation before explicitly clearing the saved key', async () => { + await mount(); + await button('settings.webSearch.clearKey').onClick?.(); + expect(api.save).not.toHaveBeenCalled(); + await button('settings.webSearch.confirmClear').onClick?.(); + expect(api.save).toHaveBeenCalledExactlyOnceWith({ clearKey: true }); + }); + + it('disables saved-key testing when no key is configured', async () => { + api.get.mockResolvedValueOnce({ enabled: false, hasKey: false }); + await mount(); + const test = find( + render(), + (props) => + props.onClick !== undefined && + Array.isArray(props.children) && + props.children.includes('settings.webSearch.test'), + ); + expect(test.disabled).toBe(true); + await test.onClick?.(); + expect(api.test).not.toHaveBeenCalled(); + }); + + it('opens only the official key page through the existing external opener', async () => { + await mount(); + const open = find( + render(), + (props) => + props.onClick !== undefined && + Array.isArray(props.children) && + props.children.includes('settings.webSearch.getKey'), + ); + await open.onClick?.(); + expect(openExternal).toHaveBeenCalledExactlyOnceWith('https://app.tavily.com'); + }); + + it('prevents concurrent probes and ignores late results after unmount', async () => { + const cleanup = await mount(); + let resolve: ((value: unknown) => void) | undefined; + api.test.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const test = find( + render(), + (props) => + props.onClick !== undefined && + Array.isArray(props.children) && + props.children.includes('settings.webSearch.test'), + ); + test.onClick?.(); + test.onClick?.(); + expect(api.test).toHaveBeenCalledTimes(1); + find(render(), (props) => props.role === 'switch').onChange?.({ target: { checked: true } }); + expect(api.save).not.toHaveBeenCalled(); + cleanup?.(); + resolve?.({ status: 'ok', message: 'sensitive response' }); + await Promise.resolve(); + expect(hooks.state[3]).toBeNull(); + }); + + it('sanitizes rejected and malformed results but retains a bounded numeric HTTP status', async () => { + await mount(); + api.test.mockRejectedValueOnce(new Error('secret raw error')); + const clickTest = () => + find( + render(), + (props) => + props.onClick !== undefined && + Array.isArray(props.children) && + props.children.includes('settings.webSearch.test'), + ).onClick?.(); + await clickTest(); + expect(hooks.state[3]).toEqual({ key: 'settings.webSearch.testFailed', error: true }); + api.test.mockResolvedValueOnce({ status: 'ok', message: 'secret raw result' }); + await clickTest(); + expect(hooks.state[3]).toEqual({ key: 'settings.webSearch.testFailed', error: true }); + api.test.mockResolvedValueOnce({ status: 'unexpected-response', httpStatus: 418 }); + await clickTest(); + expect(hooks.state[3]).toEqual({ + key: WEB_SEARCH_TEST_MESSAGES['unexpected-response'], + error: true, + httpStatus: 418, + }); + expect(renderToStaticMarkup(render())).toContain('(HTTP 418)'); + }); + + it('has localized copy for every status and the disclosure/next-run boundaries', () => { + expect(Object.keys(WEB_SEARCH_TEST_MESSAGES)).toEqual(WebSearchTestResult.shape.status.options); + for (const locale of ['en', 'zh-CN']) { + const messages = JSON.parse( + readFileSync( + new URL(`../../../../../../../packages/i18n/src/locales/${locale}.json`, import.meta.url), + 'utf8', + ), + ); + for (const key of Object.values(WEB_SEARCH_TEST_MESSAGES)) { + const leaf = key.split('.').at(-1); + expect(messages.settings.webSearch.result[leaf ?? '']).toBeTruthy(); + } + expect(messages.settings.tabs.webSearch).toBeTruthy(); + expect(messages.settings.webSearch.description).toContain('Tavily'); + expect(messages.settings.webSearch.nextRun).toBeTruthy(); + expect(messages.settings.webSearch.testHint).toContain('1 credit'); + } + const source = readFileSync(new URL('./WebSearchTab.tsx', import.meta.url), 'utf8'); + expect(source).not.toMatch( + /console\.|localStorage|sessionStorage|useCodesignStore|cleanIpcError|\.message/, + ); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/settings/WebSearchTab.tsx b/apps/desktop/src/renderer/src/components/settings/WebSearchTab.tsx new file mode 100644 index 000000000..8a75606e0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/WebSearchTab.tsx @@ -0,0 +1,266 @@ +import { useT } from '@open-codesign/i18n'; +import { + SaveWebSearchSettingsInput, + WebSearchSettingsState, + WebSearchTestResult, +} from '@open-codesign/shared'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Row, SectionTitle } from './primitives'; + +export const WEB_SEARCH_TEST_MESSAGES = { + ok: 'settings.webSearch.result.ok', + 'missing-key': 'settings.webSearch.result.missingKey', + 'unreadable-key': 'settings.webSearch.result.unreadableKey', + 'invalid-key': 'settings.webSearch.result.invalidKey', + quota: 'settings.webSearch.result.quota', + 'rate-limit': 'settings.webSearch.result.rateLimit', + timeout: 'settings.webSearch.result.timeout', + 'network-error': 'settings.webSearch.result.networkError', + 'service-error': 'settings.webSearch.result.serviceError', + 'unexpected-response': 'settings.webSearch.result.unexpectedResponse', +} as const satisfies Record; + +type Notice = { key: string; error?: boolean; httpStatus?: number }; + +export function WebSearchTab() { + const t = useT(); + const [settings, setSettings] = useState(null); + // Never hydrate credentials from saved settings or retain them outside this mounted form. + const [apiKey, setApiKey] = useState(''); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const mounted = useRef(false); + const [notice, setNotice] = useState(null); + const [confirmClear, setConfirmClear] = useState(false); + + async function loadSettings() { + setNotice(null); + try { + const bridge = window.codesign?.webSearch; + if (!bridge) throw new Error('Unavailable'); + const next = WebSearchSettingsState.parse(await bridge.get()); + if (mounted.current) setSettings(next); + } catch { + if (mounted.current) setNotice({ key: 'settings.webSearch.loadFailed', error: true }); + } + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: load only on mount, never test automatically + useEffect(() => { + mounted.current = true; + void loadSettings(); + return () => { + mounted.current = false; + }; + }, []); + + async function save(input: SaveWebSearchSettingsInput) { + if (busyRef.current || !settings) return; + setApiKey(''); + const parsed = SaveWebSearchSettingsInput.safeParse(input); + if (!parsed.success) { + setNotice({ key: 'settings.webSearch.invalidInput', error: true }); + return; + } + busyRef.current = true; + setBusy(true); + setNotice(null); + setConfirmClear(false); + try { + const bridge = window.codesign?.webSearch; + if (!bridge) throw new Error('Unavailable'); + const next = WebSearchSettingsState.parse(await bridge.save(parsed.data)); + if (!mounted.current) return; + setSettings(next); + setNotice({ + key: input.clearKey ? 'settings.webSearch.cleared' : 'settings.webSearch.saved', + }); + } catch { + if (mounted.current) setNotice({ key: 'settings.webSearch.saveFailed', error: true }); + } finally { + busyRef.current = false; + if (mounted.current) setBusy(false); + } + } + + async function testSavedKey() { + if (busyRef.current || !settings?.hasKey) return; + busyRef.current = true; + setBusy(true); + setNotice(null); + setConfirmClear(false); + try { + const bridge = window.codesign?.webSearch; + if (!bridge) throw new Error('Unavailable'); + const result = WebSearchTestResult.parse(await bridge.test()); + if (mounted.current) { + setNotice({ + key: WEB_SEARCH_TEST_MESSAGES[result.status], + error: result.status !== 'ok', + ...(result.httpStatus !== undefined ? { httpStatus: result.httpStatus } : {}), + }); + } + } catch { + if (mounted.current) setNotice({ key: 'settings.webSearch.testFailed', error: true }); + } finally { + busyRef.current = false; + if (mounted.current) setBusy(false); + } + } + + async function openKeyPage() { + try { + const bridge = window.codesign; + if (!bridge) throw new Error('Unavailable'); + await bridge.openExternal('https://app.tavily.com'); + } catch { + if (mounted.current) setNotice({ key: 'settings.webSearch.openFailed', error: true }); + } + } + + const buttonClass = + 'inline-flex h-8 items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-[var(--text-xs)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed'; + const hintClass = + 'text-[var(--text-xs)] text-[var(--color-text-muted)] leading-[var(--leading-body)]'; + + return ( +
+
+ {t('settings.webSearch.title')} +

{t('settings.webSearch.description')}

+

{t('settings.webSearch.nextRun')}

+
+ + + void save({ enabled: event.target.checked })} + className="h-4 w-4 accent-[var(--color-accent)]" + /> + + + + + {t( + !settings + ? 'settings.common.loading' + : settings.hasKey + ? 'settings.webSearch.configured' + : 'settings.webSearch.notConfigured', + )} + + + +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + void save({ apiKey: String(data.get('apiKey') ?? '') }); + }} + > + + setApiKey(event.target.value)} + autoComplete="off" + spellCheck={false} + maxLength={4096} + disabled={!settings || busy} + aria-describedby="tavily-key-hint" + placeholder={t('settings.webSearch.keyPlaceholder')} + className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-[var(--text-sm)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50" + /> +

+ {t('settings.webSearch.keyHint')} +

+
+ + + +
+
+ + {confirmClear ? ( +
+

{t('settings.webSearch.clearConfirm')}

+
+ + +
+
+ ) : null} + +
+ +

{t('settings.webSearch.testHint')}

+
+ {notice ? ( +
+

+ {t(notice.key)} + {notice.httpStatus !== undefined ? ` (HTTP ${notice.httpStatus})` : null} +

+ {!settings ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 776ad58f6..1f2d23227 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -97,6 +97,7 @@ export type AppView = 'hub' | 'workspace' | 'settings'; export type SettingsTab = | 'models' | 'images' + | 'webSearch' | 'memory' | 'appearance' | 'workspace' diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index c59c5b23c..04020059a 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -367,6 +367,7 @@ "tabs": { "models": "Models", "images": "Image API", + "webSearch": "Web Search", "memory": "Memory", "appearance": "Appearance", "workspace": "Workspace & Files", @@ -374,6 +375,48 @@ "diagnostics": "Diagnostics", "advanced": "Advanced" }, + "webSearch": { + "title": "Tavily Web Search", + "description": "Use your own Tavily API key for web search. Search queries are sent to Tavily; task authorization is still required.", + "nextRun": "Enabling, disabling, or saving settings applies to the next run, not active runs.", + "enabled": "Enable web search", + "enabledHint": "Controls web search and page fetching. Turning this off preserves your saved key. Enabling does not authorize a task to use the web.", + "keyStatus": "Saved API key", + "configured": "Configured", + "notConfigured": "Not configured", + "newKey": "Tavily API key", + "replaceKey": "Replace Tavily API key", + "keyPlaceholder": "Paste a new API key", + "keyHint": "The saved key is never loaded into this field. Save a new key to replace it, or explicitly clear it below.", + "saveKey": "Save key", + "saveReplacement": "Save replacement key", + "getKey": "Get a key at app.tavily.com", + "clearKey": "Clear saved key…", + "clearConfirm": "Remove the saved Tavily key? You will need to save a key again before searching.", + "confirmClear": "Clear saved key", + "test": "Test saved key", + "testHint": "Tests use only the saved key, not unsaved input. Clicking sends one minimal search to Tavily, costing one basic request (1 credit). Tests do not grant or consume task authorization and never run automatically.", + "retry": "Retry loading", + "saved": "Web search settings saved. Changes apply to the next run.", + "cleared": "Saved key cleared. Changes apply to the next run.", + "loadFailed": "Could not load web search settings. Try again.", + "saveFailed": "Could not save web search settings. Try again.", + "testFailed": "Could not test the saved key. Try again.", + "openFailed": "Could not open the Tavily key page. Visit https://app.tavily.com in your browser.", + "invalidInput": "Enter a non-empty API key of at most 4096 characters without spaces or control characters.", + "result": { + "ok": "Saved key verified. Tavily search is reachable.", + "missingKey": "No saved key. Save a Tavily API key first.", + "unreadableKey": "The saved key cannot be read on this device. Replace it or clear it and save a new key.", + "invalidKey": "Tavily rejected the saved key. Check it and save a replacement.", + "quota": "Tavily quota is exhausted. Check your Tavily account or plan.", + "rateLimit": "Tavily is rate limiting requests. Wait and try again.", + "timeout": "The test timed out. Check your connection and try again.", + "networkError": "Could not reach Tavily. Check your network or proxy and try again.", + "serviceError": "Tavily is temporarily unavailable. Try again later.", + "unexpectedResponse": "Tavily returned an unexpected response. Try again later." + } + }, "imageGen": { "tabTitle": "Image generation", "tabHint": "Let the design agent call an image model through OpenAI, OpenRouter, or ChatGPT subscription for heroes, posters, and background bitmaps. Credentials stay local.", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index e0d4fdb65..e1f9d59cf 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -367,6 +367,7 @@ "tabs": { "models": "模型", "images": "图像 API", + "webSearch": "联网搜索", "memory": "记忆", "appearance": "外观", "workspace": "工作区与文件", @@ -374,6 +375,48 @@ "diagnostics": "诊断", "advanced": "高级" }, + "webSearch": { + "title": "Tavily 联网搜索", + "description": "使用你自己的 Tavily API Key 进行联网搜索。搜索查询会发送给 Tavily;仍需获得任务授权。", + "nextRun": "启用、停用或保存设置仅对下一次运行生效,不影响正在进行的运行。", + "enabled": "启用联网搜索", + "enabledHint": "控制联网搜索和网页抓取。关闭后仍保留已保存的 Key。启用不代表已授权任务访问网络。", + "keyStatus": "已保存的 API Key", + "configured": "已配置", + "notConfigured": "未配置", + "newKey": "Tavily API Key", + "replaceKey": "替换 Tavily API Key", + "keyPlaceholder": "粘贴新的 API Key", + "keyHint": "已保存的 Key 不会载入此输入框。保存新 Key 可替换旧 Key,也可在下方明确清除。", + "saveKey": "保存 Key", + "saveReplacement": "保存替换 Key", + "getKey": "前往 app.tavily.com 获取 Key", + "clearKey": "清除已保存的 Key…", + "clearConfirm": "确定移除已保存的 Tavily Key?再次搜索前需要重新保存 Key。", + "confirmClear": "清除已保存的 Key", + "test": "测试已保存的 Key", + "testHint": "测试仅使用已保存的 Key,不使用尚未保存的输入。点击后会向 Tavily 发送一次最小搜索,消耗一次基础请求(1 credit)。测试不会授予或消耗任务授权,也不会自动运行。", + "retry": "重新加载", + "saved": "联网搜索设置已保存,将在下一次运行生效。", + "cleared": "已保存的 Key 已清除,将在下一次运行生效。", + "loadFailed": "无法加载联网搜索设置,请重试。", + "saveFailed": "无法保存联网搜索设置,请重试。", + "testFailed": "无法测试已保存的 Key,请重试。", + "openFailed": "无法打开 Tavily Key 页面,请在浏览器中访问 https://app.tavily.com。", + "invalidInput": "请输入非空且不超过 4096 个字符的 API Key,不可包含空格或控制字符。", + "result": { + "ok": "已保存的 Key 验证成功,可以连接 Tavily 搜索。", + "missingKey": "尚未保存 Key,请先保存 Tavily API Key。", + "unreadableKey": "无法在此设备读取已保存的 Key,请替换或清除后重新保存。", + "invalidKey": "Tavily 拒绝了已保存的 Key,请检查并保存替换 Key。", + "quota": "Tavily 额度已用尽,请检查 Tavily 账户或套餐。", + "rateLimit": "Tavily 请求过于频繁,请稍后重试。", + "timeout": "测试超时,请检查连接后重试。", + "networkError": "无法连接 Tavily,请检查网络或代理后重试。", + "serviceError": "Tavily 服务暂时不可用,请稍后重试。", + "unexpectedResponse": "Tavily 返回了意外响应,请稍后重试。" + } + }, "imageGen": { "tabTitle": "图像生成", "tabHint": "允许设计 Agent 通过 OpenAI、OpenRouter 或 ChatGPT 订阅调用图像模型,生成 hero、海报或背景位图。凭据仅保存在本机。", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fb238d618..da8c5e132 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,12 @@ export { CommentContentExpectations, commentContentFingerprint, } from './comment-content'; +export { + SaveWebSearchSettingsInput, + WEB_SEARCH_SETTINGS_CHANNELS, + WebSearchSettingsState, + WebSearchTestResult, +} from './web-search-settings'; export const ProviderId = z.enum([ 'anthropic', diff --git a/packages/shared/src/web-search-settings.test.ts b/packages/shared/src/web-search-settings.test.ts new file mode 100644 index 000000000..8bd02e7de --- /dev/null +++ b/packages/shared/src/web-search-settings.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + SaveWebSearchSettingsInput, + WEB_SEARCH_SETTINGS_CHANNELS, + WebSearchSettingsState, + WebSearchTestResult, +} from './web-search-settings'; + +describe('web search Settings IPC contracts', () => { + it('versions the channels without adding fields to the agreed wire shapes', () => { + expect(WEB_SEARCH_SETTINGS_CHANNELS).toEqual({ + get: 'settings:v1:get-web-search', + save: 'settings:v1:save-web-search', + test: 'settings:v1:test-web-search', + }); + expect(WebSearchSettingsState.parse({ enabled: false, hasKey: true })).toEqual({ + enabled: false, + hasKey: true, + }); + expect( + WebSearchSettingsState.safeParse({ enabled: true, hasKey: true, apiKey: 'secret' }).success, + ).toBe(false); + }); + + it('preserves absent keys and requires explicit clearing', () => { + expect(SaveWebSearchSettingsInput.parse({ enabled: false })).toEqual({ enabled: false }); + expect(SaveWebSearchSettingsInput.parse({ clearKey: true })).toEqual({ clearKey: true }); + expect(SaveWebSearchSettingsInput.parse({ clearKey: false })).toEqual({ clearKey: false }); + expect(SaveWebSearchSettingsInput.parse({})).toEqual({}); + expect(SaveWebSearchSettingsInput.parse({ apiKey: ' tvly-fixture ' })).toEqual({ + apiKey: 'tvly-fixture', + }); + }); + + it.each([ + { apiKey: '' }, + { apiKey: ' ' }, + { apiKey: 'key with spaces' }, + { apiKey: 'key\nheader' }, + { apiKey: 'key\u0000' }, + { apiKey: '非ASCII' }, + { apiKey: 'x'.repeat(4097) }, + { apiKey: 'key', clearKey: true }, + { enabled: 'true' }, + { clearKey: 'true' }, + { provider: 'another' }, + ])('rejects invalid or ambiguous saves %#', (input) => { + expect(SaveWebSearchSettingsInput.safeParse(input).success).toBe(false); + }); + + it('permits only sanitized test statuses and optional HTTP status', () => { + for (const status of WebSearchTestResult.shape.status.options) { + expect(WebSearchTestResult.parse({ status })).toEqual({ status }); + } + expect(WebSearchTestResult.parse({ status: 'invalid-key', httpStatus: 401 })).toEqual({ + status: 'invalid-key', + httpStatus: 401, + }); + for (const result of [ + { status: 'raw error' }, + { status: 'ok', message: 'secret' }, + { status: 'ok', httpStatus: 999 }, + { status: 'ok', httpStatus: 200.5 }, + ]) { + expect(WebSearchTestResult.safeParse(result).success).toBe(false); + } + }); +}); diff --git a/packages/shared/src/web-search-settings.ts b/packages/shared/src/web-search-settings.ts new file mode 100644 index 000000000..a37ded7b1 --- /dev/null +++ b/packages/shared/src/web-search-settings.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +export const WebSearchSettingsState = z + .object({ enabled: z.boolean(), hasKey: z.boolean() }) + .strict(); +export type WebSearchSettingsState = z.infer; + +export const SaveWebSearchSettingsInput = z + .object({ + enabled: z.boolean().optional(), + apiKey: z + .string() + .trim() + .min(1) + .max(4096) + .regex(/^[\x21-\x7e]+$/) + .optional(), + clearKey: z.boolean().optional(), + }) + .strict() + .refine((input) => !(input.clearKey && input.apiKey !== undefined), { + message: 'Cannot replace and clear a key together', + }); +export type SaveWebSearchSettingsInput = z.infer; + +export const WebSearchTestResult = z + .object({ + status: z.enum([ + 'ok', + 'missing-key', + 'unreadable-key', + 'invalid-key', + 'quota', + 'rate-limit', + 'timeout', + 'network-error', + 'service-error', + 'unexpected-response', + ]), + httpStatus: z.number().int().min(100).max(599).optional(), + }) + .strict(); +export type WebSearchTestResult = z.infer; + +export const WEB_SEARCH_SETTINGS_CHANNELS = { + get: 'settings:v1:get-web-search', + save: 'settings:v1:save-web-search', + test: 'settings:v1:test-web-search', +} as const; From 61a0fe8653a7342827fa7687beb61b98a94ebc0a Mon Sep 17 00:00:00 2001 From: HUANG <15866338256@163.com> Date: Sat, 26 Sep 2026 02:09:48 +0800 Subject: [PATCH 2/3] fix(desktop): exclude search credentials from model services Signed-off-by: HUANG <15866338256@163.com> --- .changeset/tavily-settings-entry.md | 2 +- apps/desktop/src/main/onboarding-ipc.test.ts | 4 ++ .../src/main/provider-settings.test.ts | 45 ++++++++++++++++++- apps/desktop/src/main/provider-settings.ts | 3 ++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.changeset/tavily-settings-entry.md b/.changeset/tavily-settings-entry.md index 56c08b61d..88b44e9dc 100644 --- a/.changeset/tavily-settings-entry.md +++ b/.changeset/tavily-settings-entry.md @@ -4,4 +4,4 @@ "@open-codesign/i18n": patch --- -Add bilingual Tavily web-search settings with safe key save/replace/clear operations and explicit bounded connection testing. Apply settings to new runs without restarting while retaining per-run networking consent and active-run snapshots. Keep direct webpage reading independent of Tavily credentials. +Add bilingual Tavily web-search settings with safe key save/replace/clear operations and explicit bounded connection testing. Apply settings to new runs without restarting while retaining per-run networking consent and active-run snapshots. Keep direct webpage reading independent of Tavily credentials, and exclude search-only credentials from the model API service list without deleting them. diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 02453a645..565bfbc70 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -160,6 +160,10 @@ describe('registerOnboardingIpc — channel versioning', () => { hasKey: true, }); expect(await save?.({}, { enabled: true })).toEqual({ enabled: true, hasKey: true }); + const listProviders = handlers.get('settings:v1:list-providers'); + expect(listProviders).toBeDefined(); + expect(await listProviders?.({})).toEqual([]); + expect(await get?.({})).toEqual({ enabled: true, hasKey: true }); expect(probe).not.toHaveBeenCalled(); expect(await test?.({})).toEqual({ status: 'ok' }); expect(probe).toHaveBeenCalledExactlyOnceWith('tvly-ipc-fixture', 15000); diff --git a/apps/desktop/src/main/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts index 126a83fec..99e7a0bf6 100644 --- a/apps/desktop/src/main/provider-settings.test.ts +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -4,7 +4,7 @@ import { type Config, hydrateConfig, } from '@open-codesign/shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { assertProviderHasStoredSecret, computeDeleteProviderResult, @@ -94,6 +94,49 @@ describe('getAddProviderDefaults', () => { }); describe('toProviderRows', () => { + it.each([ + { ciphertext: 'plain:tvly-fixture' }, + { ciphertext: 'safe:unreadable-fixture' }, + { ciphertext: 'legacy-fixture', mask: 'tvly***ture' }, + ])('does not list or decrypt Tavily credentials as a model service: %j', (tavily) => { + const cfg = makeCfg({ + provider: 'openai', + modelPrimary: 'gpt-4o', + secrets: { tavily }, + }); + cfg.webSearch = { enabled: true, maxCalls: 7, timeoutMs: 4000, maxChars: 3000 }; + const before = structuredClone(cfg); + const decrypt = vi.fn(() => { + throw new Error('Search credentials must not be decrypted for model settings'); + }); + + const rows = toProviderRows(cfg, decrypt); + + expect(rows.map((row) => row.provider)).toEqual(['anthropic', 'openai', 'openrouter']); + expect(JSON.stringify(rows)).not.toContain('tvly'); + expect(decrypt).not.toHaveBeenCalled(); + expect(cfg).toEqual(before); + }); + + it('keeps recognized legacy model keys without provider entries but excludes search-only keys', () => { + const cfg = hydrateConfig({ + version: 3, + activeProvider: '', + activeModel: '', + providers: {}, + secrets: { + openai: { ciphertext: 'plain:model-fixture', mask: '***' }, + tavily: { ciphertext: 'plain:tvly-fixture' }, + }, + }); + const decrypt = vi.fn(() => 'unused'); + + expect(toProviderRows(cfg, decrypt)).toEqual([ + expect.objectContaining({ provider: 'openai', hasKey: true, maskedKey: '***' }), + ]); + expect(decrypt).not.toHaveBeenCalled(); + }); + it('returns a row with error:decryption_failed and empty maskedKey when decrypt throws', () => { const cfg = makeCfg({ provider: 'openai', diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts index 385c7698d..def8d82cc 100644 --- a/apps/desktop/src/main/provider-settings.ts +++ b/apps/desktop/src/main/provider-settings.ts @@ -114,6 +114,9 @@ export function toProviderRows( for (const provider of allIds) { const ref = cfg.secrets?.[provider]; const entry = resolveEntryFor(cfg, provider); + // Secrets also contain non-model credentials such as Tavily. Only actual + // model entries (or recognized legacy builtins) belong in the model list. + if (entry === null) continue; let maskedKey = ''; let rowError: ProviderRow['error']; From f90c761ea93ac60740208b70857072aa40634ebb Mon Sep 17 00:00:00 2001 From: HUANG <15866338256@163.com> Date: Sat, 26 Sep 2026 02:33:31 +0800 Subject: [PATCH 3/3] test(desktop): include secrets in generation config fixture Signed-off-by: HUANG <15866338256@163.com> --- apps/desktop/src/main/ipc/generate.workspace-rename.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts index 1c547f240..8da2f2da2 100644 --- a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts +++ b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts @@ -137,6 +137,7 @@ vi.mock('../onboarding-ipc', () => ({ getCachedConfig: vi.fn(() => ({ provider: 'mock-provider', modelPrimary: 'mock-model', + secrets: {}, designSystem: null, })), hasApiKeyForProvider: vi.fn(() => true),