Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tavily-settings-entry.md
Original file line number Diff line number Diff line change
@@ -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, and exclude search-only credentials from the model API service list without deleting them.
39 changes: 35 additions & 4 deletions WEB_SEARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.

Expand Down Expand Up @@ -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
```
35 changes: 9 additions & 26 deletions apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -690,6 +690,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
designId: string,
previousSource: string | null,
workspaceRoot: string,
researchRun: ReturnType<typeof createWebResearchRun>,
attachmentsForRuntimeFs?: Parameters<typeof createRuntimeTextEditorFs>[0]['attachments'],
memoryCallbacks?: {
onAggressivePrune?: () => void;
Expand All @@ -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<string, number>();
const templatesRoot = path_module.join(app.getPath('userData'), 'templates');
const currentWorkspaceRoot = () => requireWorkspaceRootForDesign(designId).workspaceRoot;
Expand All @@ -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<typeof createWebResearchNetwork> | 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(), {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1357,6 +1337,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
designId,
payload.previousSource ?? null,
workspaceRoot,
researchRun,
promptContext.attachments,
{
onAggressivePrune: () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1714,6 +1696,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
payload.designId,
payload.artifactSource,
workspaceRoot,
researchRun,
promptContext.attachments,
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ vi.mock('../onboarding-ipc', () => ({
getCachedConfig: vi.fn(() => ({
provider: 'mock-provider',
modelPrimary: 'mock-model',
secrets: {},
designSystem: null,
})),
hasApiKeyForProvider: vi.fn(() => true),
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,48 @@ 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 });
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);
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();

Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/main/onboarding/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<ValidateResult> => {
Expand Down
45 changes: 44 additions & 1 deletion apps/desktop/src/main/provider-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
Loading
Loading