From 480f66d59effb505af4026c3b1d4aef48c8027b9 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sat, 23 May 2026 17:48:50 +0800 Subject: [PATCH 1/2] feat(desktop): add per-provider TLS verification bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in tlsRejectUnauthorized boolean to ProviderEntry plus a ref-counted withTlsBypass(enabled, fn) helper that swaps the global undici dispatcher for one with rejectUnauthorized:false around outbound HTTPS calls. Built-in providers (anthropic, openai, openrouter, ollama) force-ignore the flag so a tampered config cannot weaken trusted endpoints. The toggle surfaces only on non-built-in providers in AddCustomProviderModal, gated behind a confirm prompt on first enable, and rendered as a yellow "TLS verify off" badge on the provider row. Threading covers all outbound paths: connection-test (test-active / test-provider / config:v1:test-endpoint), models discovery (models:v1:list-for-provider), and generation (codesign:v1:generate + codesign:v1:generate-title via pi-ai). The known concurrency window — parallel requests during the bypass interval inherit the loose dispatcher — is documented in tls-override.ts and the design spec. Unblocks users on corporate networks whose internal OpenAI-compatible gateways serve self-signed or private-CA certificates that Node 22's built-in fetch (undici) cannot otherwise accept, since NODE_TLS_REJECT_UNAUTHORIZED is intentionally ignored by undici. Closes #229. --- .changeset/feat-tls-verify-toggle.md | 6 + apps/desktop/package.json | 3 +- apps/desktop/src/main/connection-ipc.test.ts | 162 ++++++++++ apps/desktop/src/main/connection-ipc.ts | 146 +++++---- apps/desktop/src/main/ipc/generate.ts | 285 ++++++++++-------- .../src/main/onboarding/provider-parsers.ts | 25 ++ .../src/main/onboarding/providers-crud.ts | 10 + apps/desktop/src/main/provider-settings.ts | 4 + apps/desktop/src/main/tls-override.test.ts | 136 +++++++++ apps/desktop/src/main/tls-override.ts | 87 ++++++ apps/desktop/src/preload/index.ts | 9 + .../src/components/AddCustomProviderModal.tsx | 81 ++++- .../src/components/settings/ModelsTab.tsx | 1 + .../src/components/settings/primitives.tsx | 9 + packages/i18n/src/locales/en.json | 12 +- packages/i18n/src/locales/es.json | 12 +- packages/i18n/src/locales/pt-BR.json | 12 +- packages/i18n/src/locales/zh-CN.json | 12 +- packages/shared/src/config.test.ts | 69 +++++ packages/shared/src/config.ts | 10 + pnpm-lock.yaml | 3 + 21 files changed, 906 insertions(+), 188 deletions(-) create mode 100644 .changeset/feat-tls-verify-toggle.md create mode 100644 apps/desktop/src/main/tls-override.test.ts create mode 100644 apps/desktop/src/main/tls-override.ts diff --git a/.changeset/feat-tls-verify-toggle.md b/.changeset/feat-tls-verify-toggle.md new file mode 100644 index 00000000..9f9a01e6 --- /dev/null +++ b/.changeset/feat-tls-verify-toggle.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/desktop": minor +"@open-codesign/shared": minor +--- + +feat(desktop): add per-provider "Disable TLS verification" toggle for custom and imported providers. Unblocks connections to corporate gateways with self-signed or private-CA certificates that Node 22's built-in fetch cannot otherwise accept. Built-in providers (Anthropic, OpenAI, OpenRouter, Ollama) remain unaffected. (#229) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e96e237d..ade63d54 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,7 +22,8 @@ "dependencies": { "jszip": "^3.10.1", "ms": "2.1.3", - "puppeteer-core": "^24.42.0" + "puppeteer-core": "^24.42.0", + "undici": "^7.25.0" }, "devDependencies": { "@mariozechner/pi-agent-core": "^0.72.1", diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts index 0c603835..5a101f81 100644 --- a/apps/desktop/src/main/connection-ipc.test.ts +++ b/apps/desktop/src/main/connection-ipc.test.ts @@ -5,6 +5,14 @@ vi.mock('./electron-runtime', () => ({ ipcMain: { handle: vi.fn() }, })); +// Mock the TLS-bypass wrapper so tests can assert the enabled flag passed to +// it without actually swapping the global undici dispatcher mid-test. The +// pass-through impl preserves the existing fetch path so all other suites in +// this file (which rely on installFakeFetch) keep working unchanged. +vi.mock('./tls-override', () => ({ + withTlsBypass: vi.fn(async (_enabled: boolean, fn: () => Promise) => fn()), +})); + import { createHash } from 'node:crypto'; import { _clearModelsCache, @@ -23,6 +31,7 @@ import { normalizeOllamaBaseUrl, runProviderTest, } from './connection-ipc'; +import { withTlsBypass } from './tls-override'; // --------------------------------------------------------------------------- // Thin test-only handler that exercises the same fetch/parse/cache path @@ -1386,3 +1395,156 @@ describe('config:v1:test-endpoint response parsing', () => { } }); }); + +// --------------------------------------------------------------------------- +// TLS bypass plumbing — every outbound HTTP entrypoint must (a) consult +// withTlsBypass exactly once and (b) gate the `enabled` arg by the +// `builtin !== true && tlsRejectUnauthorized === true` rule so a tampered +// config can never weaken TLS for built-in providers. +// --------------------------------------------------------------------------- + +describe('TLS bypass routing', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.mocked(withTlsBypass).mockClear(); + }); + + function lastBypassEnabled(): boolean | undefined { + const calls = vi.mocked(withTlsBypass).mock.calls; + const last = calls.at(-1); + return last?.[0]; + } + + it('runProviderTest: built-in provider with tlsRejectUnauthorized=true is forced to enabled=false', async () => { + const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } })); + try { + const res = await runProviderTest({ + provider: 'anthropic', + wire: 'anthropic', + apiKey: 'sk-ant-test', + baseUrl: 'https://api.anthropic.com', + builtin: true, + tlsRejectUnauthorized: true, + }); + expect(res.ok).toBe(true); + expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1); + expect(lastBypassEnabled()).toBe(false); + } finally { + restore(); + } + }); + + it('runProviderTest: non-built-in provider with tlsRejectUnauthorized=true → enabled=true', async () => { + const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } })); + try { + const res = await runProviderTest({ + provider: 'internal-gateway', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://internal.example.corp/v1', + builtin: false, + tlsRejectUnauthorized: true, + }); + expect(res.ok).toBe(true); + expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1); + expect(lastBypassEnabled()).toBe(true); + } finally { + restore(); + } + }); + + it('runProviderTest: non-built-in with tlsRejectUnauthorized=false → enabled=false', async () => { + const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } })); + try { + const res = await runProviderTest({ + provider: 'internal-gateway', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://internal.example.corp/v1', + builtin: false, + tlsRejectUnauthorized: false, + }); + expect(res.ok).toBe(true); + expect(lastBypassEnabled()).toBe(false); + } finally { + restore(); + } + }); + + it('runProviderTest: non-built-in with tlsRejectUnauthorized=undefined → enabled=false', async () => { + const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } })); + try { + const res = await runProviderTest({ + provider: 'internal-gateway', + wire: 'openai-chat', + apiKey: 'sk-test', + baseUrl: 'https://internal.example.corp/v1', + builtin: false, + }); + expect(res.ok).toBe(true); + expect(lastBypassEnabled()).toBe(false); + } finally { + restore(); + } + }); + + it('handleConfigV1TestEndpoint: payload with tlsRejectUnauthorized=true → enabled=true', async () => { + const { restore } = installFakeFetch(() => ({ + status: 200, + body: { data: [{ id: 'gpt-x' }] }, + })); + try { + const res = await handleConfigV1TestEndpoint({ + wire: 'openai-chat', + baseUrl: 'https://internal.example.corp/v1', + apiKey: 'sk-test', + tlsRejectUnauthorized: true, + }); + expect(res).toEqual({ ok: true, modelCount: 1, models: ['gpt-x'] }); + expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1); + expect(lastBypassEnabled()).toBe(true); + } finally { + restore(); + } + }); + + it('handleConfigV1TestEndpoint: missing tlsRejectUnauthorized → enabled=false', async () => { + const { restore } = installFakeFetch(() => ({ + status: 200, + body: { data: [{ id: 'gpt-x' }] }, + })); + try { + await handleConfigV1TestEndpoint({ + wire: 'openai-chat', + baseUrl: 'https://internal.example.corp/v1', + apiKey: 'sk-test', + }); + expect(lastBypassEnabled()).toBe(false); + } finally { + restore(); + } + }); + + it('handleConfigV1TestEndpoint: rejects non-boolean tlsRejectUnauthorized before fetch', async () => { + const { restore } = installFakeFetch(() => { + throw new Error('fetch should not be called'); + }); + try { + await expect( + handleConfigV1TestEndpoint({ + wire: 'openai-chat', + baseUrl: 'https://provider.example/v1', + apiKey: 'sk-test', + tlsRejectUnauthorized: 'yes', + }), + ).resolves.toEqual({ + ok: false, + error: 'bad-input', + message: 'tlsRejectUnauthorized must be a boolean', + }); + expect(vi.mocked(withTlsBypass)).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index 1852ed21..1041c9d0 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -18,6 +18,7 @@ import { getCodexTokenStore } from './codex-oauth-ipc'; import { ipcMain } from './electron-runtime'; import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc'; import { isKeylessProviderAllowed } from './provider-settings'; +import { withTlsBypass } from './tls-override'; // Re-export so existing importers (tests, other main-process modules) keep // working after the helpers moved to `./auth-headers` to break a circular @@ -48,6 +49,7 @@ const TEST_ENDPOINT_FIELDS = [ 'apiKey', 'httpHeaders', 'allowPrivateNetwork', + 'tlsRejectUnauthorized', ] as const; function assertKnownFields( @@ -479,6 +481,16 @@ export interface ActiveProviderCredentials { apiKey: string; baseUrl: string; httpHeaders?: Record; + /** + * True when the provider entry came from BUILTIN_PROVIDERS rather than user + * config. Built-ins force-ignore `tlsRejectUnauthorized` so a tampered + * config can never weaken outbound TLS for official endpoints. Optional so + * that test fixtures can omit it (treated as non-builtin, which is the safe + * default — the flag is checked against `!== true` everywhere). + */ + builtin?: boolean; + /** Opt-in TLS verification bypass; only honored when `builtin === false`. */ + tlsRejectUnauthorized?: boolean; } function resolveCredentialsForProvider( @@ -525,7 +537,11 @@ function resolveCredentialsForProvider( wire: entry.wire, apiKey, baseUrl: entry.baseUrl, + builtin: entry.builtin === true, ...(entry.httpHeaders !== undefined ? { httpHeaders: entry.httpHeaders } : {}), + ...(entry.tlsRejectUnauthorized !== undefined + ? { tlsRejectUnauthorized: entry.tlsRejectUnauthorized } + : {}), }; } @@ -590,57 +606,63 @@ export async function runProviderTest( return testChatGPTCodexOAuth(); } - const { url, normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl); - const headers = buildAuthHeadersForWire( - creds.wire, - creds.apiKey, - creds.httpHeaders, - creds.baseUrl, - ); + // Bypass is the per-provider opt-in, force-gated so a tampered config can + // never weaken TLS for built-in providers. Wrapping the whole body covers + // both the GET /models probe and the inner POST inside tryDegradeProbe. + const bypass = creds.builtin !== true && creds.tlsRejectUnauthorized === true; + return withTlsBypass(bypass, async () => { + const { url, normalizedBaseUrl } = buildEndpointForWire(creds.wire, creds.baseUrl); + const headers = buildAuthHeadersForWire( + creds.wire, + creds.apiKey, + creds.httpHeaders, + creds.baseUrl, + ); - let res: Response; - try { - res = await fetchWithTimeout(url, { method: 'GET', headers }); - } catch (err) { - const { code, hint } = classifyNetworkError(err); - return { - ok: false, - code, - message: err instanceof Error ? err.message : 'Network request failed', - hint, - compatibility: 'incompatible', - reasonCategory: code === 'ECONNREFUSED' ? 'network-unreachable' : 'unknown', - }; - } - if (!res.ok) { - // Some OpenAI-compatible gateways (Zhipu GLM, a handful of self-hosted - // proxies) don't expose /models but their /chat/completions works fine. - // If the primary probe 404s on those wires, degrade-probe with a tiny - // chat request before declaring the endpoint dead. We intentionally do - // not degrade anthropic — its /v1/models is standard, and skipping it - // would mask real path-shape mistakes. - if ( - res.status === 404 && - (creds.wire === 'openai-chat' || - creds.wire === 'openai-responses' || - creds.wire === 'anthropic') - ) { - const degraded = await tryDegradeProbe(creds.wire, normalizedBaseUrl, headers); - if (degraded !== null) return degraded; - // Inference endpoint also 404'd (or the network dropped) — fall through - // and report the original /models 404. + let res: Response; + try { + res = await fetchWithTimeout(url, { method: 'GET', headers }); + } catch (err) { + const { code, hint } = classifyNetworkError(err); + return { + ok: false, + code, + message: err instanceof Error ? err.message : 'Network request failed', + hint, + compatibility: 'incompatible', + reasonCategory: code === 'ECONNREFUSED' ? 'network-unreachable' : 'unknown', + }; } - const { code, hint } = classifyHttpError(res.status); - return { - ok: false, - code, - message: `HTTP ${res.status}`, - hint, - compatibility: 'incompatible', - reasonCategory: connectionCategoryForStatus(res.status, normalizedBaseUrl), - }; - } - return { ok: true, probeMethod: 'models', compatibility: 'compatible' }; + if (!res.ok) { + // Some OpenAI-compatible gateways (Zhipu GLM, a handful of self-hosted + // proxies) don't expose /models but their /chat/completions works fine. + // If the primary probe 404s on those wires, degrade-probe with a tiny + // chat request before declaring the endpoint dead. We intentionally do + // not degrade anthropic — its /v1/models is standard, and skipping it + // would mask real path-shape mistakes. + if ( + res.status === 404 && + (creds.wire === 'openai-chat' || + creds.wire === 'openai-responses' || + creds.wire === 'anthropic') + ) { + const degraded = await tryDegradeProbe(creds.wire, normalizedBaseUrl, headers); + if (degraded !== null) return degraded; + // Inference endpoint also 404'd (or the network dropped) — fall through + // and report the original /models 404. + } + const { code, hint } = classifyHttpError(res.status); + return { + ok: false, + code, + message: `HTTP ${res.status}`, + hint, + compatibility: 'incompatible', + reasonCategory: connectionCategoryForStatus(res.status, normalizedBaseUrl), + }; + } + return { ok: true, probeMethod: 'models', compatibility: 'compatible' }; + }); } async function tryDegradeProbe( @@ -971,10 +993,13 @@ async function handleModelsV1ListForProvider(raw: unknown): Promise + fetchModelListResponse(url, headers, { + message: 'Unexpected models response shape', + hint: 'Check provider /models endpoint compatibility', + }), + ); if (result.ok) setCachedModels(providerId, entry.baseUrl, apiKey, result.models); return result; } @@ -1065,9 +1090,15 @@ export async function handleConfigV1TestEndpoint(raw: unknown): Promise + fetchWithTimeout(url, { method: 'GET', headers }), + ); } catch (err) { return { ok: false, @@ -1222,6 +1253,7 @@ interface TestEndpointPayload { apiKey: string; httpHeaders?: Record; allowPrivateNetwork?: boolean; + tlsRejectUnauthorized?: boolean; } export type TestEndpointResponse = @@ -1258,6 +1290,12 @@ function parseTestEndpointPayload(raw: unknown): TestEndpointPayload { } out.allowPrivateNetwork = r['allowPrivateNetwork']; } + if (r['tlsRejectUnauthorized'] !== undefined) { + if (typeof r['tlsRejectUnauthorized'] !== 'boolean') { + throw new CodesignError('tlsRejectUnauthorized must be a boolean', ERROR_CODES.IPC_BAD_INPUT); + } + out.tlsRejectUnauthorized = r['tlsRejectUnauthorized']; + } const headers = parseTestEndpointHttpHeaders(r['httpHeaders']); if (headers !== undefined) out.httpHeaders = headers; return out; diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 815de4f8..b9d42ef4 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -25,6 +25,7 @@ import { ApplyCommentPayload, CancelGenerationPayloadV1, CodesignError, + type Config, deriveResourceStateFromChatRows, GeneratePayloadV1, } from '@open-codesign/shared'; @@ -70,6 +71,7 @@ import { type SessionChatStoreOptions, } from '../session-chat'; import { type Database, getDesign, recordDiagnosticEvent } from '../snapshots-db'; +import { withTlsBypass } from '../tls-override'; import { withStableWorkspacePath } from '../workspace-path-lock'; import { listWorkspaceFilesAt, readWorkspaceFilesAt } from '../workspace-reader'; import { finalAssistantTextForTurn } from './assistant-text'; @@ -85,6 +87,17 @@ export function contextWindowForContextPack(model: unknown): number { : DEFAULT_CONTEXT_WINDOW_FOR_CONTEXT_PACK; } +/** + * Whether outbound pi-ai requests for the given provider should bypass TLS + * certificate verification. True only for non-built-in providers whose + * persisted entry opts in via `tlsRejectUnauthorized: true`. Built-in + * providers force-ignore the flag (security floor — see issue #229). + */ +function resolveTlsBypassFor(cfg: Config, providerId: string): boolean { + const entry = cfg.providers?.[providerId]; + return entry !== undefined && entry.builtin !== true && entry.tlsRejectUnauthorized === true; +} + export function shouldRunUserMemoryCandidateCapture(prefs: { memoryEnabled: boolean; userMemoryAutoUpdate: boolean; @@ -773,6 +786,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe coreLogger.info('[generate] step=validate_provider.ok', { provider: active.model.provider, }); + const tlsBypass = resolveTlsBypassFor(cfg, active.model.provider); const prefs = await readPreferences(); const { designId, workspaceRoot, promptContext, memoryContext, memoryLoadWarning } = @@ -863,25 +877,27 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe hasReferenceUrl: promptContext.referenceUrl !== null, hasDesignSystem: promptContext.designSystem !== null, }; - const routedPreferences = await routeRunPreferences({ - prompt: payload.prompt, - existingPreferences: existingRunPreferences, - recentHistory: recentHistoryForRunPreferenceRouter(chatRows), - workspaceState, - designBrief: existingBrief ? JSON.stringify(existingBrief) : null, - userMemory: memoryContext?.userMemory?.content ?? null, - workspaceMemory: memoryContext?.workspaceMemory?.content ?? null, - model: active.model, - apiKey, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - logger: coreLogger, - }); + const routedPreferences = await withTlsBypass(tlsBypass, () => + routeRunPreferences({ + prompt: payload.prompt, + existingPreferences: existingRunPreferences, + recentHistory: recentHistoryForRunPreferenceRouter(chatRows), + workspaceState, + designBrief: existingBrief ? JSON.stringify(existingBrief) : null, + userMemory: memoryContext?.userMemory?.content ?? null, + workspaceMemory: memoryContext?.workspaceMemory?.content ?? null, + model: active.model, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + logger: coreLogger, + }), + ); let runPreferences = routedPreferences.preferences; const runProtocolPreflight = buildRunProtocolPreflight({ prompt: payload.prompt, @@ -1000,50 +1016,52 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe generationId: id, ...contextPack.trace, }); - const result = await runGenerate( - { - prompt: payload.prompt, - history: contextPack.history, - model: active.model, - apiKey, - ...(isCodex - ? { getApiKey: () => resolveActiveApiKeyFromState(active.model.provider) } - : {}), - attachments: promptContext.attachments, - referenceUrl: promptContext.referenceUrl, - designSystem: promptContext.designSystem ?? null, - sessionContext: [ - ...contextPack.contextSections, - ...formatRunProtocolPreflightAnswers(preflightAnswers), - ], - ...(memoryContext !== undefined ? { memoryContext: memoryContext.sections } : {}), - projectContext: promptContext.projectContext, - currentDesignName, - initialResourceState: resourceState, - runPreferences, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - signal: controller.signal, - logger: coreLogger, - }, - id, - designId, - payload.previousSource ?? null, - workspaceRoot, - promptContext.attachments, - { - onAggressivePrune: () => { - aggressivePruneDetected = true; + const result = await withTlsBypass(tlsBypass, () => + runGenerate( + { + prompt: payload.prompt, + history: contextPack.history, + model: active.model, + apiKey, + ...(isCodex + ? { getApiKey: () => resolveActiveApiKeyFromState(active.model.provider) } + : {}), + attachments: promptContext.attachments, + referenceUrl: promptContext.referenceUrl, + designSystem: promptContext.designSystem ?? null, + sessionContext: [ + ...contextPack.contextSections, + ...formatRunProtocolPreflightAnswers(preflightAnswers), + ], + ...(memoryContext !== undefined ? { memoryContext: memoryContext.sections } : {}), + projectContext: promptContext.projectContext, + currentDesignName, + initialResourceState: resourceState, + runPreferences, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + signal: controller.signal, + logger: coreLogger, }, - onComplete: (messages) => { - capturedMessages = messages; + id, + designId, + payload.previousSource ?? null, + workspaceRoot, + promptContext.attachments, + { + onAggressivePrune: () => { + aggressivePruneDetected = true; + }, + onComplete: (messages) => { + capturedMessages = messages; + }, }, - }, + ), ); logIpc.info('generate.ok', { generationId: id, @@ -1069,26 +1087,28 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe prefs.memoryEnabled === true && prefs.workspaceMemoryAutoUpdate === true ? (() => { const startedAt = Date.now(); - return triggerWorkspaceMemoryUpdate({ - workspacePath: memoryWorkspaceRoot, - workspaceName: workspaceNameFromPath(memoryWorkspaceRoot), - designId, - designName, - conversationMessages: messagesForMemory, - userMemory: memoryContext?.userMemory?.content ?? null, - designMdSummary: designMdSummaryForMemory(promptContext.projectContext), - model: active.model, - apiKey, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined - ? { httpHeaders: active.httpHeaders } - : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - }) + return withTlsBypass(tlsBypass, () => + triggerWorkspaceMemoryUpdate({ + workspacePath: memoryWorkspaceRoot, + workspaceName: workspaceNameFromPath(memoryWorkspaceRoot), + designId, + designName, + conversationMessages: messagesForMemory, + userMemory: memoryContext?.userMemory?.content ?? null, + designMdSummary: designMdSummaryForMemory(promptContext.projectContext), + model: active.model, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined + ? { httpHeaders: active.httpHeaders } + : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + }), + ) .then((workspaceMemory) => { if ( workspaceMemory !== null && @@ -1154,29 +1174,31 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const briefUpdate = prefs.memoryEnabled ? workspaceMemoryUpdate .then((workspaceMemory) => - updateDesignSessionBrief({ - existingBrief, - conversationMessages: messagesForMemory, - designId, - designName, - userMemory: memoryContext?.userMemory?.content ?? null, - workspaceMemory: workspaceMemory?.content ?? null, - sourceUserMemoryHash: memoryContext?.userMemory?.hash, - sourceWorkspaceMemoryHash: workspaceMemory?.hash, - sourceMemoryUpdatedAt: - workspaceMemory?.updatedAt ?? memoryContext?.userMemory?.updatedAt, - model: active.model, - apiKey, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined - ? { httpHeaders: active.httpHeaders } - : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - }), + withTlsBypass(tlsBypass, () => + updateDesignSessionBrief({ + existingBrief, + conversationMessages: messagesForMemory, + designId, + designName, + userMemory: memoryContext?.userMemory?.content ?? null, + workspaceMemory: workspaceMemory?.content ?? null, + sourceUserMemoryHash: memoryContext?.userMemory?.hash, + sourceWorkspaceMemoryHash: workspaceMemory?.hash, + sourceMemoryUpdatedAt: + workspaceMemory?.updatedAt ?? memoryContext?.userMemory?.updatedAt, + model: active.model, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined + ? { httpHeaders: active.httpHeaders } + : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + }), + ), ) .then((briefResult) => { const opts = chatStoreOptions(); @@ -1201,19 +1223,21 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe userMessages: extractUserMessagesForMemory(messagesForMemory), }) .then(() => { - return triggerUserMemoryConsolidation({ - model: active.model, - apiKey, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined - ? { httpHeaders: active.httpHeaders } - : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - }); + return withTlsBypass(tlsBypass, () => + triggerUserMemoryConsolidation({ + model: active.model, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined + ? { httpHeaders: active.httpHeaders } + : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + }), + ); }) .catch((err) => { logIpc.warn('user-memory.maintenance.fail', { @@ -1443,23 +1467,28 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); const baseUrl = active.baseUrl ?? undefined; + const tlsBypass = resolveTlsBypassFor(cfg, active.model.provider); const titleLogger: CoreLogger = { info: (event, data) => logIpc.info(event, data), warn: (event, data) => logIpc.warn(event, data), error: (event, data) => logIpc.error(event, data), }; try { - return await generateTitle({ - prompt, - model: active.model, - apiKey, - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), - ...(active.reasoningLevel !== undefined ? { reasoningLevel: active.reasoningLevel } : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - logger: titleLogger, - }); + return await withTlsBypass(tlsBypass, () => + generateTitle({ + prompt, + model: active.model, + apiKey, + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + logger: titleLogger, + }), + ); } catch (err) { logIpc.error('[title] generate-title.fail', { provider: active.model.provider, diff --git a/apps/desktop/src/main/onboarding/provider-parsers.ts b/apps/desktop/src/main/onboarding/provider-parsers.ts index 701c4e6e..65b2dfa2 100644 --- a/apps/desktop/src/main/onboarding/provider-parsers.ts +++ b/apps/desktop/src/main/onboarding/provider-parsers.ts @@ -37,6 +37,9 @@ export interface AddCustomProviderInput { httpHeaders?: Record; queryParams?: Record; envKey?: string; + /** Per-provider TLS verification opt-out (#229). Built-in providers + * force-ignore this flag at runtime. */ + tlsRejectUnauthorized?: boolean; setAsActive: boolean; } @@ -53,6 +56,9 @@ export interface UpdateProviderInput { * Empty string means "clear stored secret" for providers that became * keyless (e.g. switched to local Ollama). `undefined` means "leave alone". */ apiKey?: string; + /** Tri-state: `true`/`false` writes the field; `null` clears it back to + * the default (strict TLS); `undefined` leaves the existing value alone. */ + tlsRejectUnauthorized?: boolean | null; } const SAVE_KEY_FIELDS = ['provider', 'apiKey', 'modelPrimary', 'baseUrl'] as const; @@ -67,6 +73,7 @@ const ADD_PROVIDER_FIELDS = [ 'httpHeaders', 'queryParams', 'envKey', + 'tlsRejectUnauthorized', 'setAsActive', ] as const; const UPDATE_PROVIDER_FIELDS = [ @@ -79,6 +86,7 @@ const UPDATE_PROVIDER_FIELDS = [ 'wire', 'reasoningLevel', 'apiKey', + 'tlsRejectUnauthorized', ] as const; function assertKnownFields( @@ -308,6 +316,12 @@ export function parseAddProviderPayload(raw: unknown): AddCustomProviderInput { } out.envKey = r['envKey'].trim(); } + if (r['tlsRejectUnauthorized'] !== undefined) { + if (typeof r['tlsRejectUnauthorized'] !== 'boolean') { + throw new CodesignError('tlsRejectUnauthorized must be a boolean', ERROR_CODES.IPC_BAD_INPUT); + } + out.tlsRejectUnauthorized = r['tlsRejectUnauthorized']; + } return out; } @@ -373,5 +387,16 @@ export function parseUpdateProviderPayload(raw: unknown): UpdateProviderInput { } out.apiKey = r['apiKey']; } + if (r['tlsRejectUnauthorized'] === null) { + out.tlsRejectUnauthorized = null; + } else if (r['tlsRejectUnauthorized'] !== undefined) { + if (typeof r['tlsRejectUnauthorized'] !== 'boolean') { + throw new CodesignError( + 'tlsRejectUnauthorized must be a boolean or null', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + out.tlsRejectUnauthorized = r['tlsRejectUnauthorized']; + } return out; } diff --git a/apps/desktop/src/main/onboarding/providers-crud.ts b/apps/desktop/src/main/onboarding/providers-crud.ts index c7faa96e..107d954a 100644 --- a/apps/desktop/src/main/onboarding/providers-crud.ts +++ b/apps/desktop/src/main/onboarding/providers-crud.ts @@ -214,6 +214,7 @@ export async function runAddCustomProvider( ...(input.httpHeaders !== undefined ? { httpHeaders: input.httpHeaders } : {}), ...(input.queryParams !== undefined ? { queryParams: input.queryParams } : {}), ...(input.envKey !== undefined ? { envKey: input.envKey } : {}), + ...(input.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), }; const secretRef = buildSecretRef(input.apiKey); const nextProviders = { ...(cachedConfig?.providers ?? {}), [entry.id]: entry }; @@ -268,6 +269,15 @@ export async function runUpdateProvider(input: UpdateProviderInput): Promise ({ + setGlobalDispatcherMock: vi.fn(), + getGlobalDispatcherMock: vi.fn(), + agentInstances: [] as Array<{ connect?: { rejectUnauthorized?: boolean } | undefined }>, +})); + +vi.mock('undici', () => { + class FakeAgent { + constructor(opts?: { connect?: { rejectUnauthorized?: boolean } }) { + agentInstances.push({ connect: opts?.connect }); + } + } + return { + Agent: FakeAgent, + setGlobalDispatcher: (...args: unknown[]) => setGlobalDispatcherMock(...args), + getGlobalDispatcher: (...args: unknown[]) => getGlobalDispatcherMock(...args), + }; +}); + +const { loggerWarn, loggerError } = vi.hoisted(() => ({ + loggerWarn: vi.fn(), + loggerError: vi.fn(), +})); +vi.mock('./logger', () => ({ + getLogger: () => ({ + warn: loggerWarn, + error: loggerError, + info: vi.fn(), + debug: vi.fn(), + }), +})); + +import { _getTlsBypassRefCount, _resetTlsOverrideForTesting, withTlsBypass } from './tls-override'; + +const ORIGINAL_DISPATCHER = { id: 'original' } as const; + +beforeEach(() => { + _resetTlsOverrideForTesting(); + setGlobalDispatcherMock.mockReset(); + getGlobalDispatcherMock.mockReset(); + loggerWarn.mockReset(); + loggerError.mockReset(); + agentInstances.length = 0; + getGlobalDispatcherMock.mockReturnValue(ORIGINAL_DISPATCHER); +}); + +describe('withTlsBypass', () => { + it('does not touch the dispatcher when disabled', async () => { + const result = await withTlsBypass(false, async () => 'ok'); + expect(result).toBe('ok'); + expect(setGlobalDispatcherMock).not.toHaveBeenCalled(); + expect(getGlobalDispatcherMock).not.toHaveBeenCalled(); + expect(loggerWarn).not.toHaveBeenCalled(); + expect(_getTlsBypassRefCount()).toBe(0); + }); + + it('installs loose dispatcher on first acquire and restores on release', async () => { + const observed: Array = []; + const result = await withTlsBypass(true, async () => { + observed.push(setGlobalDispatcherMock.mock.calls.length); + return 'done'; + }); + expect(result).toBe('done'); + expect(observed[0]).toBe(1); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(2); + expect(setGlobalDispatcherMock.mock.calls[1]?.[0]).toBe(ORIGINAL_DISPATCHER); + expect(agentInstances).toHaveLength(1); + expect(agentInstances[0]?.connect?.rejectUnauthorized).toBe(false); + expect(_getTlsBypassRefCount()).toBe(0); + }); + + it('reuses the loose dispatcher across nested acquires (swap once, restore once)', async () => { + await withTlsBypass(true, async () => { + await withTlsBypass(true, async () => { + await withTlsBypass(true, async () => { + expect(_getTlsBypassRefCount()).toBe(3); + }); + expect(_getTlsBypassRefCount()).toBe(2); + }); + expect(_getTlsBypassRefCount()).toBe(1); + }); + expect(_getTlsBypassRefCount()).toBe(0); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(2); + expect(agentInstances).toHaveLength(1); + }); + + it('restores the dispatcher even when fn throws', async () => { + await expect( + withTlsBypass(true, async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + expect(_getTlsBypassRefCount()).toBe(0); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(2); + expect(setGlobalDispatcherMock.mock.calls[1]?.[0]).toBe(ORIGINAL_DISPATCHER); + }); + + it('emits a warn per acquire', async () => { + await withTlsBypass(true, async () => { + await withTlsBypass(true, async () => {}); + }); + expect(loggerWarn).toHaveBeenCalledTimes(2); + expect(loggerWarn.mock.calls[0]?.[0]).toContain('refcount=1'); + expect(loggerWarn.mock.calls[1]?.[0]).toContain('refcount=2'); + }); + + it('handles parallel bypass calls — swap on first start, restore after last finishes', async () => { + const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + }; + const a = deferred(); + const b = deferred(); + const aPromise = withTlsBypass(true, () => a.promise); + const bPromise = withTlsBypass(true, () => b.promise); + await Promise.resolve(); + expect(_getTlsBypassRefCount()).toBe(2); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(1); + + a.resolve(); + await aPromise; + expect(_getTlsBypassRefCount()).toBe(1); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(1); + + b.resolve(); + await bPromise; + expect(_getTlsBypassRefCount()).toBe(0); + expect(setGlobalDispatcherMock).toHaveBeenCalledTimes(2); + expect(setGlobalDispatcherMock.mock.calls[1]?.[0]).toBe(ORIGINAL_DISPATCHER); + }); +}); diff --git a/apps/desktop/src/main/tls-override.ts b/apps/desktop/src/main/tls-override.ts new file mode 100644 index 00000000..d58de12b --- /dev/null +++ b/apps/desktop/src/main/tls-override.ts @@ -0,0 +1,87 @@ +import { Agent, type Dispatcher, getGlobalDispatcher, setGlobalDispatcher } from 'undici'; +import { getLogger } from './logger'; + +/** + * Per-provider TLS verification bypass. + * + * Some users run open-codesign against internal OpenAI-compatible gateways + * served with self-signed or private-CA certificates. Node 22's built-in fetch + * is implemented by undici, which intentionally ignores + * NODE_TLS_REJECT_UNAUTHORIZED. The only working bypass is to install a + * dispatcher whose connect agent has rejectUnauthorized:false. + * + * This helper exposes a single high-order wrapper, withTlsBypass(enabled, fn). + * When enabled, it swaps the global dispatcher for a loose one before invoking + * fn and restores the original in finally. A ref count keeps nested or + * overlapping bypass-enabled calls from clobbering each other: the loose + * dispatcher is installed once on the 0→1 transition and the original is + * restored on the n→0 transition. + * + * Known concurrency window: while a bypass call is in flight, any other + * outbound HTTPS request issued from the main process (including from + * built-in providers or unrelated bypass-disabled providers) uses the loose + * dispatcher too. In practice this is rare because the app issues serial + * requests per user, but if the user manually parallelizes a strict + * provider's request with a bypass-enabled provider's request, the strict + * one will silently skip verification for the overlap. Documented in + * docs/superpowers/specs/2026-05-23-tls-bypass-design.md §9. + */ + +const log = getLogger('tls-override'); + +let refCount = 0; +let savedDispatcher: Dispatcher | null = null; +let looseDispatcher: Dispatcher | null = null; + +function getLooseDispatcher(): Dispatcher { + if (looseDispatcher === null) { + looseDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + } + return looseDispatcher; +} + +function acquireTlsBypass(): void { + if (refCount === 0) { + savedDispatcher = getGlobalDispatcher(); + setGlobalDispatcher(getLooseDispatcher()); + } + refCount++; + log.warn(`TLS verification bypassed for outbound request (refcount=${refCount})`); +} + +function releaseTlsBypass(): void { + if (refCount === 0) { + // Defensive: a release without a matching acquire indicates a logic bug. + // Logging surfaces it; do not throw because callers run inside finally + // blocks and a throw here would mask the real error. + log.error('releaseTlsBypass called with refcount already 0'); + return; + } + refCount--; + if (refCount === 0) { + if (savedDispatcher !== null) setGlobalDispatcher(savedDispatcher); + savedDispatcher = null; + } +} + +export async function withTlsBypass(enabled: boolean, fn: () => Promise): Promise { + if (!enabled) return fn(); + acquireTlsBypass(); + try { + return await fn(); + } finally { + releaseTlsBypass(); + } +} + +/** Test-only: reset module state between vitest cases. */ +export function _resetTlsOverrideForTesting(): void { + refCount = 0; + savedDispatcher = null; + looseDispatcher = null; +} + +/** Test-only: inspect refcount for assertions. */ +export function _getTlsBypassRefCount(): number { + return refCount; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index cf7eca5d..57ed6965 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -199,6 +199,10 @@ export interface ProviderRow { defaultModel: string; hasKey: boolean; reasoningLevel?: ReasoningLevel; + /** Per-provider opt-in to skip TLS verification on outbound HTTPS. + * Built-in providers force-ignore this flag at runtime; only surfaced + * for custom/imported providers. See #229. */ + tlsRejectUnauthorized?: boolean; error?: 'decryption_failed' | string; } @@ -519,6 +523,7 @@ const api = { httpHeaders?: Record; queryParams?: Record; envKey?: string; + tlsRejectUnauthorized?: boolean; setAsActive: boolean; }) => ipcRenderer.invoke('config:v1:add-provider', input) as Promise, updateProvider: (input: { @@ -535,6 +540,9 @@ const api = { /** Non-empty string rotates the stored secret; empty string clears it * (keyless providers); omit to leave the existing secret untouched. */ apiKey?: string; + /** Per-provider TLS verification opt-out (#229). Omit to leave + * untouched; `false`/`true` writes the field through. */ + tlsRejectUnauthorized?: boolean; }) => ipcRenderer.invoke('config:v1:update-provider', input) as Promise, removeProvider: (id: string) => ipcRenderer.invoke('config:v1:remove-provider', id) as Promise, @@ -549,6 +557,7 @@ const api = { apiKey: string; httpHeaders?: Record; allowPrivateNetwork?: boolean; + tlsRejectUnauthorized?: boolean; }) => ipcRenderer.invoke('config:v1:test-endpoint', input) as Promise, listEndpointModels: (input: { wire: WireApi; baseUrl: string; apiKey: string }) => ipcRenderer.invoke('config:v1:list-endpoint-models', input) as Promise< diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx index 8adf483a..5fa3e36d 100644 --- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx +++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx @@ -39,6 +39,9 @@ interface Props { * placeholder so user knows there's a stored key, and an empty submit * doesn't wipe it. */ keyMask?: string; + /** Existing per-provider TLS verification opt-out, so the checkbox can + * start in the right state when re-opening Edit. */ + tlsRejectUnauthorized?: boolean; }; } @@ -74,17 +77,20 @@ export function buildEndpointDiscoveryPayload( wire: WireApi, baseUrl: string, allowPrivateNetwork: boolean, + tlsRejectUnauthorized = false, ): { wire: WireApi; baseUrl: string; apiKey: string; allowPrivateNetwork: boolean; + tlsRejectUnauthorized?: boolean; } { return { wire, baseUrl: baseUrl.trim(), apiKey: '', allowPrivateNetwork, + ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), }; } @@ -119,6 +125,14 @@ export function AddCustomProviderModal({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(false); + // Per-provider TLS verification opt-out. Gated to non-builtin entries + // because connection-ipc / generate.ts force-ignore the flag for builtins. + const [tlsRejectUnauthorized, setTlsRejectUnauthorized] = useState( + editTarget?.tlsRejectUnauthorized === true, + ); + // Acknowledge the security warning once per modal session so re-toggling + // doesn't re-prompt. Mirrors the allow-private-network pattern intent. + const tlsConfirmed = useRef(editTarget?.tlsRejectUnauthorized === true); const [discovery, setDiscovery] = useState({ kind: 'idle' }); // When true, user explicitly chose to type a model name instead of picking from the dropdown. @@ -155,7 +169,12 @@ export function AddCustomProviderModal({ setDiscovery({ kind: 'discovering' }); try { const res = await window.codesign.config.testEndpoint( - buildEndpointDiscoveryPayload(currentWire, currentBaseUrl, privateNetworkAllowed), + buildEndpointDiscoveryPayload( + currentWire, + currentBaseUrl, + privateNetworkAllowed, + tlsRejectUnauthorized, + ), ); if (seq !== discoverySeq.current) return; if (res.ok && res.models.length > 0) { @@ -199,6 +218,40 @@ export function AddCustomProviderModal({ userPickedModel.current = v.length > 0; } + // Only show the TLS toggle for non-built-in providers — the runtime + // force-ignores the field on built-ins, and surfacing it there would + // mislead users into thinking the bypass would take effect. + const showTlsToggle = !isEdit || editTarget?.builtin !== true; + + function handleTlsToggle(nextChecked: boolean) { + if (!nextChecked) { + setTlsRejectUnauthorized(false); + setTest({ kind: 'idle' }); + scheduleDiscovery(baseUrl, wire); + return; + } + // window.confirm matches the existing in-renderer confirmation pattern + // (see ChatgptLoginCard) — packages/ui ships no AlertDialog primitive and + // adding Radix here would introduce a dep for a single one-shot prompt. + // We acknowledge once per modal session so re-toggling doesn't re-nag. + if (tlsConfirmed.current) { + setTlsRejectUnauthorized(true); + setTest({ kind: 'idle' }); + scheduleDiscovery(baseUrl, wire); + return; + } + const ok = window.confirm( + `${t('settings.providers.tlsRejectUnauthorized.confirmTitle')}\n\n${t( + 'settings.providers.tlsRejectUnauthorized.confirmBody', + )}`, + ); + if (!ok) return; + tlsConfirmed.current = true; + setTlsRejectUnauthorized(true); + setTest({ kind: 'idle' }); + scheduleDiscovery(baseUrl, wire); + } + async function handleTest() { if (!window.codesign?.config) return; if (baseUrl.trim().length === 0) return; @@ -209,6 +262,7 @@ export function AddCustomProviderModal({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), allowPrivateNetwork, + ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), }); if (res.ok) setTest({ kind: 'ok', modelCount: res.modelCount }); else setTest({ kind: 'error', message: res.message }); @@ -241,6 +295,12 @@ export function AddCustomProviderModal({ } const typedKey = apiKey.trim(); if (typedKey.length > 0) update.apiKey = typedKey; + if (!editTarget.builtin) { + const previous = editTarget.tlsRejectUnauthorized === true; + if (previous !== tlsRejectUnauthorized) { + update.tlsRejectUnauthorized = tlsRejectUnauthorized ? true : false; + } + } await window.codesign.config.updateProvider(update); } else { const slug = slugify(name); @@ -253,6 +313,7 @@ export function AddCustomProviderModal({ apiKey: apiKey.trim(), defaultModel: defaultModel.trim(), setAsActive: initialSetAsActive, + ...(tlsRejectUnauthorized ? { tlsRejectUnauthorized: true } : {}), }); } onSave(); @@ -384,6 +445,24 @@ export function AddCustomProviderModal({ )} + {showTlsToggle && ( + + )} diff --git a/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx b/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx index 72df4906..6e355fcd 100644 --- a/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/ModelsTab.tsx @@ -772,6 +772,7 @@ export function ModelsTab() { builtin: editingRow.builtin, lockEndpoint: editingRow.builtin, ...(editingRow.maskedKey.length > 0 ? { keyMask: editingRow.maskedKey } : {}), + ...(editingRow.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), }} initialSetAsActive={false} /> diff --git a/apps/desktop/src/renderer/src/components/settings/primitives.tsx b/apps/desktop/src/renderer/src/components/settings/primitives.tsx index 5d51b3b4..8c5e4e97 100644 --- a/apps/desktop/src/renderer/src/components/settings/primitives.tsx +++ b/apps/desktop/src/renderer/src/components/settings/primitives.tsx @@ -360,6 +360,15 @@ export function ProviderCard({ {t('settings.providers.missingKey')} ) : null} + {row.builtin !== true && row.tlsRejectUnauthorized === true && ( + + + {t('settings.providers.tlsRejectUnauthorized.badge')} + + )} {row.baseUrl && ( diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index f3054465..0751b4df 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -445,6 +445,16 @@ "save": "Save & continue", "saveEdit": "Save changes" }, + "tlsRejectUnauthorized": { + "label": "Disable TLS verification (insecure — internal endpoints only)", + "description": "Skip certificate verification for this provider. Use only for internal endpoints with self-signed or private-CA certificates.", + "confirmTitle": "Disable TLS verification?", + "confirmBody": "Outbound HTTPS requests to this provider will not verify the server certificate. Anyone on the network path between you and this endpoint could intercept or impersonate it. Only enable this for trusted internal networks.", + "confirmAccept": "Yes, disable verification", + "confirmCancel": "Cancel", + "badge": "TLS verify off", + "badgeTooltip": "TLS certificate verification is disabled for this provider. Used to connect to internal endpoints with self-signed or private-CA certificates. Increases attack surface — only enable for trusted networks." + }, "import": { "action": "Import", "dismiss": "Dismiss", @@ -1028,7 +1038,7 @@ "hostUnreachable": "Cannot reach host — check domain, port, or VPN.", "timedOut": "Request timed out — check firewall or VPN.", "corsError": "CORS error (should not happen in main process). This is a bug.", - "sslError": "SSL / certificate error (self-signed cert on relay?).", + "sslError": "SSL / certificate error (self-signed or private-CA cert?). Enable Settings → Providers → → Disable TLS verification to connect to trusted internal endpoints.", "endpointNotFound": "The endpoint path exists in the Base URL but the provider did not expose this route.", "gatewayIncompatible": "The gateway accepted the connection but does not implement this provider's API. Try switching wire (e.g. openai-chat).", "gatewayWafBlocked": "The gateway or reverse proxy blocked the generation request before it reached the model. Test Connection can still pass because it only probes the /models endpoint.", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 47f1a52b..4534391a 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -392,6 +392,16 @@ "save": "Guardar y continuar", "saveEdit": "Guardar cambios" }, + "tlsRejectUnauthorized": { + "label": "Desactivar verificación TLS (inseguro — solo para endpoints internos)", + "description": "Omitir la verificación del certificado para este proveedor. Úsalo solo con endpoints internos que usan certificados autofirmados o de una CA privada.", + "confirmTitle": "¿Desactivar la verificación TLS?", + "confirmBody": "Las solicitudes HTTPS salientes hacia este proveedor no verificarán el certificado del servidor. Cualquiera con acceso a la ruta de red entre tú y este endpoint podría interceptarlo o suplantarlo. Actívalo solo en redes internas de confianza.", + "confirmAccept": "Sí, desactivar la verificación", + "confirmCancel": "Cancelar", + "badge": "TLS sin verificar", + "badgeTooltip": "La verificación del certificado TLS está desactivada para este proveedor. Se usa para conectar con endpoints internos que tienen certificados autofirmados o de una CA privada. Aumenta la superficie de ataque — actívalo solo en redes de confianza." + }, "import": { "action": "Importar", "dismiss": "Descartar", @@ -973,7 +983,7 @@ "hostUnreachable": "No se puede alcanzar el host — revisa dominio, puerto o VPN.", "timedOut": "Tiempo de espera de la solicitud agotado — revisa el cortafuegos o VPN.", "corsError": "Error de CORS (no debería pasar en el proceso principal). Esto es un error.", - "sslError": "Error de SSL / certificado (¿certificado autofirmado en la retransmisión?).", + "sslError": "Error de SSL / certificado (¿certificado autofirmado o CA privada?). Actívalo en Ajustes → Proveedores → este proveedor → Desactivar verificación TLS para conectarte a endpoints internos de confianza.", "gatewayIncompatible": "La pasarela aceptó la conexión pero no implementa la API de este proveedor. Intenta cambiar de protocolo (ej. openai-chat).", "gatewayWafBlocked": "La pasarela o el proxy inverso bloqueó la generación antes de llegar al modelo. Test Connection puede pasar porque solo prueba el endpoint /models.", "openaiResponsesMisconfigured": "El punto final rechazó la forma de la solicitud. El protocolo puede ser incorrecto — intenta cambiar a openai-chat.", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index a3b6568b..2505e8e6 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -357,6 +357,16 @@ "switchToDropdown": "Escolher da lista", "switchToManual": "Digitar manualmente" }, + "tlsRejectUnauthorized": { + "label": "Desativar verificação TLS (inseguro — apenas para endpoints internos)", + "description": "Pula a verificação do certificado deste provedor. Use apenas em endpoints internos com certificados autoassinados ou emitidos por uma CA privada.", + "confirmTitle": "Desativar a verificação TLS?", + "confirmBody": "As requisições HTTPS para este provedor não verificarão mais o certificado do servidor. Qualquer pessoa com acesso ao caminho de rede entre você e este endpoint poderia interceptá-lo ou se passar por ele. Ative apenas em redes internas confiáveis.", + "confirmAccept": "Sim, desativar a verificação", + "confirmCancel": "Cancelar", + "badge": "TLS sem verificação", + "badgeTooltip": "A verificação do certificado TLS está desativada para este provedor. Usada para conectar a endpoints internos com certificados autoassinados ou de CA privada. Aumenta a superfície de ataque — ative apenas em redes confiáveis." + }, "import": { "action": "Importar", "dismiss": "Dispensar", @@ -934,7 +944,7 @@ "hostUnreachable": "Não foi possível acessar o host — verifique domínio, porta ou VPN.", "timedOut": "Tempo da requisição esgotado — verifique firewall ou VPN.", "corsError": "Erro de CORS (não deveria acontecer no processo principal). Isso é um bug.", - "sslError": "Erro de SSL / certificado (certificado autoassinado no relay?).", + "sslError": "Erro de SSL / certificado (certificado autoassinado ou CA privada?). Ative em Configurações → Provedores → este provedor → Desativar verificação TLS para conectar a endpoints internos confiáveis.", "endpointNotFound": "A URL base já contém um caminho de versão, mas o provedor não expôs esta rota.", "gatewayIncompatible": "O gateway aceitou a conexão, mas não implementa a API deste provedor. Tente trocar o wire, por exemplo para openai-chat.", "gatewayWafBlocked": "O gateway ou proxy reverso bloqueou a geração antes de ela chegar ao modelo. Test Connection ainda pode passar porque só verifica o endpoint /models.", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 92f91392..34a6e23e 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -445,6 +445,16 @@ "save": "保存并继续", "saveEdit": "保存修改" }, + "tlsRejectUnauthorized": { + "label": "禁用 TLS 校验(不安全 — 仅用于内网端点)", + "description": "跳过该服务商的证书校验。仅在自签名或私有 CA 颁发证书的内网端点上使用。", + "confirmTitle": "确认禁用 TLS 校验?", + "confirmBody": "向该服务商发出的 HTTPS 请求将不再验证服务器证书。处于网络路径上的任何人都可能拦截或冒充该端点。请只在可信内网环境下启用。", + "confirmAccept": "确认禁用", + "confirmCancel": "取消", + "badge": "TLS 校验已关", + "badgeTooltip": "已为该服务商关闭 TLS 证书校验。用于连接使用自签名或私有 CA 证书的内网端点;会扩大攻击面,仅建议在可信网络中启用。" + }, "import": { "action": "导入", "dismiss": "忽略", @@ -1024,7 +1034,7 @@ "hostUnreachable": "无法连接到主机——检查域名、端口或 VPN。", "timedOut": "请求超时——检查防火墙或 VPN。", "corsError": "CORS 跨域错误(主进程中不应出现此错误,这是一个 Bug)。", - "sslError": "SSL / 证书错误(中转服务使用了自签证书?)。", + "sslError": "SSL / 证书错误(自签名或私有 CA 证书?)。可在「设置 → 服务商 → 该条目 → 禁用 TLS 校验」启用对可信内网端点的连接。", "endpointNotFound": "Base URL 已包含版本路径,但 Provider 没有暴露这个接口路径。", "gatewayIncompatible": "网关接受了连接但没有实现该 Provider 的 API。尝试切换 wire(例如改为 openai-chat)。", "gatewayWafBlocked": "网关或反代在请求到达模型前拦截了生成请求。测试连接可能仍然通过,因为它只探测 /models 端点。", diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index 4e06ac75..129396be 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -230,6 +230,75 @@ describe('config v3 schema', () => { }), ).toThrow(); }); + + it('accepts an optional tlsRejectUnauthorized flag', () => { + const base = { + version: 3 as const, + activeProvider: 'anthropic', + activeModel: 'claude-sonnet-4-6', + secrets: {}, + providers: { + anthropic: BUILTIN_PROVIDERS.anthropic, + 'corp-bedrock': { + id: 'corp-bedrock', + name: 'Corp Bedrock', + builtin: false, + wire: 'openai-chat' as const, + baseUrl: 'https://gateway.corp.internal/v1', + defaultModel: 'anthropic.claude-sonnet-4', + tlsRejectUnauthorized: true, + }, + }, + }; + const parsed = ConfigV3Schema.parse(base); + expect(parsed.providers['corp-bedrock']?.tlsRejectUnauthorized).toBe(true); + }); + + it('rejects non-boolean tlsRejectUnauthorized', () => { + expect(() => + ConfigV3Schema.parse({ + version: 3, + activeProvider: 'anthropic', + activeModel: 'claude-sonnet-4-6', + secrets: {}, + providers: { + anthropic: BUILTIN_PROVIDERS.anthropic, + 'corp-bedrock': { + id: 'corp-bedrock', + name: 'Corp Bedrock', + builtin: false, + wire: 'openai-chat', + baseUrl: 'https://gateway.corp.internal/v1', + defaultModel: 'anthropic.claude-sonnet-4', + tlsRejectUnauthorized: 'yes', + }, + }, + }), + ).toThrow(); + }); + + it('round-trips a tlsRejectUnauthorized provider through toPersistedV3', () => { + const cfg = ConfigV3Schema.parse({ + version: 3, + activeProvider: 'corp-bedrock', + activeModel: 'anthropic.claude-sonnet-4', + secrets: {}, + providers: { + 'corp-bedrock': { + id: 'corp-bedrock', + name: 'Corp Bedrock', + builtin: false, + wire: 'openai-chat', + baseUrl: 'https://gateway.corp.internal/v1', + defaultModel: 'anthropic.claude-sonnet-4', + tlsRejectUnauthorized: true, + }, + }, + }); + const hydrated = hydrateConfig(cfg); + const persisted = toPersistedV3(hydrated); + expect(persisted.providers['corp-bedrock']?.tlsRejectUnauthorized).toBe(true); + }); }); describe('migrateLegacyToV3', () => { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index fd319c60..12aac774 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -180,6 +180,16 @@ export const ProviderEntrySchema = z */ reasoningLevel: ReasoningLevelSchema.optional(), capabilities: ProviderCapabilitiesSchema.optional(), + /** + * Per-provider opt-in to skip TLS certificate verification on outbound + * HTTPS requests for this provider. Intended for users on corporate + * networks whose internal OpenAI-compatible gateways are served with + * self-signed or private-CA certificates that Node's default trust + * store rejects. Built-in providers force-ignore this flag at runtime + * (see apps/desktop/src/main/connection-ipc.ts and + * apps/desktop/src/main/ipc/generate.ts). See issue #229. + */ + tlsRejectUnauthorized: z.boolean().optional(), }) .strict(); export type ProviderEntry = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd12cc87..66a2c79c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: puppeteer-core: specifier: ^24.42.0 version: 24.42.0 + undici: + specifier: ^7.25.0 + version: 7.25.0 devDependencies: '@mariozechner/pi-agent-core': specifier: ^0.72.1 From aca58c9ab112dfb4786f36b0e80416e8581b95d4 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 24 May 2026 00:24:14 +0800 Subject: [PATCH 2/2] fix(desktop): apply TLS bypass to codesign:apply-comment handler The apply-comment IPC handler also calls runGenerate against the active provider; left unwrapped in the original change it would surface SSL errors on inline-edit operations for users who enabled the bypass on a self-signed gateway. Wrap runGenerate the same way the codesign:v1:generate and codesign:v1:generate-title handlers do, deriving tlsBypass from the same resolveTlsBypassFor helper. Addresses codex bot review #1 on PR #355. --- apps/desktop/src/main/ipc/generate.ts | 67 ++++++++++++++------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index b9d42ef4..874f33d0 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -1329,6 +1329,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const allowKeyless = active.allowKeyless; const apiKey = await resolveApiKeyForActive(active.model.provider, allowKeyless); const baseUrl = active.baseUrl ?? undefined; + const tlsBypass = resolveTlsBypassFor(cfg, active.model.provider); const { workspaceRoot, promptContext } = await withStableWorkspacePath( payload.designId, @@ -1376,38 +1377,40 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe ); clearTimeoutGuard = await armTimeout(id, controller); const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID; - const result = await runGenerate( - { - prompt: userPrompt, - systemPrompt, - history: [], - model: active.model, - apiKey, - ...(isCodex - ? { getApiKey: () => resolveActiveApiKeyFromState(active.model.provider) } - : {}), - attachments: promptContext.attachments, - referenceUrl: promptContext.referenceUrl, - designSystem: promptContext.designSystem ?? null, - projectContext: promptContext.projectContext, - initialResourceState: deriveResourceStateFromChatRows( - chatRowsForDesign(payload.designId), - ), - ...(baseUrl !== undefined ? { baseUrl } : {}), - wire: active.wire, - ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), - ...(active.reasoningLevel !== undefined - ? { reasoningLevel: active.reasoningLevel } - : {}), - ...(allowKeyless ? { allowKeyless: true } : {}), - signal: controller.signal, - logger: coreLogger, - }, - id, - payload.designId, - payload.artifactSource, - workspaceRoot, - promptContext.attachments, + const result = await withTlsBypass(tlsBypass, () => + runGenerate( + { + prompt: userPrompt, + systemPrompt, + history: [], + model: active.model, + apiKey, + ...(isCodex + ? { getApiKey: () => resolveActiveApiKeyFromState(active.model.provider) } + : {}), + attachments: promptContext.attachments, + referenceUrl: promptContext.referenceUrl, + designSystem: promptContext.designSystem ?? null, + projectContext: promptContext.projectContext, + initialResourceState: deriveResourceStateFromChatRows( + chatRowsForDesign(payload.designId), + ), + ...(baseUrl !== undefined ? { baseUrl } : {}), + wire: active.wire, + ...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}), + ...(active.reasoningLevel !== undefined + ? { reasoningLevel: active.reasoningLevel } + : {}), + ...(allowKeyless ? { allowKeyless: true } : {}), + signal: controller.signal, + logger: coreLogger, + }, + id, + payload.designId, + payload.artifactSource, + workspaceRoot, + promptContext.attachments, + ), ); logIpc.info('applyComment.ok', { generationId: id,