Skip to content

Commit 480f66d

Browse files
committed
feat(desktop): add per-provider TLS verification bypass
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.
1 parent 209263d commit 480f66d

21 files changed

Lines changed: 906 additions & 188 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@open-codesign/desktop": minor
3+
"@open-codesign/shared": minor
4+
---
5+
6+
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)

apps/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"dependencies": {
2323
"jszip": "^3.10.1",
2424
"ms": "2.1.3",
25-
"puppeteer-core": "^24.42.0"
25+
"puppeteer-core": "^24.42.0",
26+
"undici": "^7.25.0"
2627
},
2728
"devDependencies": {
2829
"@mariozechner/pi-agent-core": "^0.72.1",

apps/desktop/src/main/connection-ipc.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ vi.mock('./electron-runtime', () => ({
55
ipcMain: { handle: vi.fn() },
66
}));
77

8+
// Mock the TLS-bypass wrapper so tests can assert the enabled flag passed to
9+
// it without actually swapping the global undici dispatcher mid-test. The
10+
// pass-through impl preserves the existing fetch path so all other suites in
11+
// this file (which rely on installFakeFetch) keep working unchanged.
12+
vi.mock('./tls-override', () => ({
13+
withTlsBypass: vi.fn(async (_enabled: boolean, fn: () => Promise<unknown>) => fn()),
14+
}));
15+
816
import { createHash } from 'node:crypto';
917
import {
1018
_clearModelsCache,
@@ -23,6 +31,7 @@ import {
2331
normalizeOllamaBaseUrl,
2432
runProviderTest,
2533
} from './connection-ipc';
34+
import { withTlsBypass } from './tls-override';
2635

2736
// ---------------------------------------------------------------------------
2837
// Thin test-only handler that exercises the same fetch/parse/cache path
@@ -1386,3 +1395,156 @@ describe('config:v1:test-endpoint response parsing', () => {
13861395
}
13871396
});
13881397
});
1398+
1399+
// ---------------------------------------------------------------------------
1400+
// TLS bypass plumbing — every outbound HTTP entrypoint must (a) consult
1401+
// withTlsBypass exactly once and (b) gate the `enabled` arg by the
1402+
// `builtin !== true && tlsRejectUnauthorized === true` rule so a tampered
1403+
// config can never weaken TLS for built-in providers.
1404+
// ---------------------------------------------------------------------------
1405+
1406+
describe('TLS bypass routing', () => {
1407+
beforeEach(() => {
1408+
vi.useRealTimers();
1409+
vi.mocked(withTlsBypass).mockClear();
1410+
});
1411+
1412+
function lastBypassEnabled(): boolean | undefined {
1413+
const calls = vi.mocked(withTlsBypass).mock.calls;
1414+
const last = calls.at(-1);
1415+
return last?.[0];
1416+
}
1417+
1418+
it('runProviderTest: built-in provider with tlsRejectUnauthorized=true is forced to enabled=false', async () => {
1419+
const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } }));
1420+
try {
1421+
const res = await runProviderTest({
1422+
provider: 'anthropic',
1423+
wire: 'anthropic',
1424+
apiKey: 'sk-ant-test',
1425+
baseUrl: 'https://api.anthropic.com',
1426+
builtin: true,
1427+
tlsRejectUnauthorized: true,
1428+
});
1429+
expect(res.ok).toBe(true);
1430+
expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1);
1431+
expect(lastBypassEnabled()).toBe(false);
1432+
} finally {
1433+
restore();
1434+
}
1435+
});
1436+
1437+
it('runProviderTest: non-built-in provider with tlsRejectUnauthorized=true → enabled=true', async () => {
1438+
const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } }));
1439+
try {
1440+
const res = await runProviderTest({
1441+
provider: 'internal-gateway',
1442+
wire: 'openai-chat',
1443+
apiKey: 'sk-test',
1444+
baseUrl: 'https://internal.example.corp/v1',
1445+
builtin: false,
1446+
tlsRejectUnauthorized: true,
1447+
});
1448+
expect(res.ok).toBe(true);
1449+
expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1);
1450+
expect(lastBypassEnabled()).toBe(true);
1451+
} finally {
1452+
restore();
1453+
}
1454+
});
1455+
1456+
it('runProviderTest: non-built-in with tlsRejectUnauthorized=false → enabled=false', async () => {
1457+
const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } }));
1458+
try {
1459+
const res = await runProviderTest({
1460+
provider: 'internal-gateway',
1461+
wire: 'openai-chat',
1462+
apiKey: 'sk-test',
1463+
baseUrl: 'https://internal.example.corp/v1',
1464+
builtin: false,
1465+
tlsRejectUnauthorized: false,
1466+
});
1467+
expect(res.ok).toBe(true);
1468+
expect(lastBypassEnabled()).toBe(false);
1469+
} finally {
1470+
restore();
1471+
}
1472+
});
1473+
1474+
it('runProviderTest: non-built-in with tlsRejectUnauthorized=undefined → enabled=false', async () => {
1475+
const { restore } = installFakeFetch(() => ({ status: 200, body: { data: [] } }));
1476+
try {
1477+
const res = await runProviderTest({
1478+
provider: 'internal-gateway',
1479+
wire: 'openai-chat',
1480+
apiKey: 'sk-test',
1481+
baseUrl: 'https://internal.example.corp/v1',
1482+
builtin: false,
1483+
});
1484+
expect(res.ok).toBe(true);
1485+
expect(lastBypassEnabled()).toBe(false);
1486+
} finally {
1487+
restore();
1488+
}
1489+
});
1490+
1491+
it('handleConfigV1TestEndpoint: payload with tlsRejectUnauthorized=true → enabled=true', async () => {
1492+
const { restore } = installFakeFetch(() => ({
1493+
status: 200,
1494+
body: { data: [{ id: 'gpt-x' }] },
1495+
}));
1496+
try {
1497+
const res = await handleConfigV1TestEndpoint({
1498+
wire: 'openai-chat',
1499+
baseUrl: 'https://internal.example.corp/v1',
1500+
apiKey: 'sk-test',
1501+
tlsRejectUnauthorized: true,
1502+
});
1503+
expect(res).toEqual({ ok: true, modelCount: 1, models: ['gpt-x'] });
1504+
expect(vi.mocked(withTlsBypass)).toHaveBeenCalledTimes(1);
1505+
expect(lastBypassEnabled()).toBe(true);
1506+
} finally {
1507+
restore();
1508+
}
1509+
});
1510+
1511+
it('handleConfigV1TestEndpoint: missing tlsRejectUnauthorized → enabled=false', async () => {
1512+
const { restore } = installFakeFetch(() => ({
1513+
status: 200,
1514+
body: { data: [{ id: 'gpt-x' }] },
1515+
}));
1516+
try {
1517+
await handleConfigV1TestEndpoint({
1518+
wire: 'openai-chat',
1519+
baseUrl: 'https://internal.example.corp/v1',
1520+
apiKey: 'sk-test',
1521+
});
1522+
expect(lastBypassEnabled()).toBe(false);
1523+
} finally {
1524+
restore();
1525+
}
1526+
});
1527+
1528+
it('handleConfigV1TestEndpoint: rejects non-boolean tlsRejectUnauthorized before fetch', async () => {
1529+
const { restore } = installFakeFetch(() => {
1530+
throw new Error('fetch should not be called');
1531+
});
1532+
try {
1533+
await expect(
1534+
handleConfigV1TestEndpoint({
1535+
wire: 'openai-chat',
1536+
baseUrl: 'https://provider.example/v1',
1537+
apiKey: 'sk-test',
1538+
tlsRejectUnauthorized: 'yes',
1539+
}),
1540+
).resolves.toEqual({
1541+
ok: false,
1542+
error: 'bad-input',
1543+
message: 'tlsRejectUnauthorized must be a boolean',
1544+
});
1545+
expect(vi.mocked(withTlsBypass)).not.toHaveBeenCalled();
1546+
} finally {
1547+
restore();
1548+
}
1549+
});
1550+
});

0 commit comments

Comments
 (0)