diff --git a/.changeset/harden-provider-boundaries.md b/.changeset/harden-provider-boundaries.md
new file mode 100644
index 00000000..8cbb4831
--- /dev/null
+++ b/.changeset/harden-provider-boundaries.md
@@ -0,0 +1,5 @@
+---
+"@open-codesign/desktop": patch
+---
+
+Harden desktop provider setup by sanitizing agent-supplied SVG choice icons, storing new API keys with Electron safeStorage when available, redacting encrypted secret rows from diagnostics, and requiring explicit opt-in before testing local or private-network provider URLs.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 24b45d47..f2e95d63 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,25 @@ jobs:
cache: pnpm
- name: Install
- run: pnpm install --frozen-lockfile
+ run: env -u ELECTRON_SKIP_BINARY_DOWNLOAD -u ELECTRON_SKIP_DOWNLOAD pnpm install --frozen-lockfile --ignore-scripts=false
+
+ - name: Install Electron binary
+ run: |
+ env -u ELECTRON_SKIP_BINARY_DOWNLOAD -u ELECTRON_SKIP_DOWNLOAD pnpm -C apps/desktop exec node -e "
+ const { execFileSync } = require('node:child_process');
+ const path = require('node:path');
+ const electronDir = path.dirname(require.resolve('electron/package.json'));
+ execFileSync(process.execPath, [path.join(electronDir, 'install.js')], { stdio: 'inherit' });
+ "
+
+ - name: Verify Electron binary
+ run: |
+ env -u ELECTRON_SKIP_BINARY_DOWNLOAD -u ELECTRON_SKIP_DOWNLOAD pnpm -C apps/desktop exec node -e "
+ const fs = require('node:fs');
+ const electronPath = require('electron');
+ fs.accessSync(electronPath, fs.constants.X_OK);
+ console.log(electronPath);
+ "
- name: Lint
run: pnpm lint
diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts
index 6e5091fa..0c603835 100644
--- a/apps/desktop/src/main/connection-ipc.test.ts
+++ b/apps/desktop/src/main/connection-ipc.test.ts
@@ -12,6 +12,7 @@ import {
buildAuthHeadersForWire,
CONNECTION_FETCH_TIMEOUT_MS,
classifyHttpError,
+ classifyNetworkTarget,
extractIds,
extractModelIds,
fetchWithTimeout,
@@ -1202,6 +1203,81 @@ describe('config:v1:test-endpoint response parsing', () => {
}
});
+ it('classifies private and metadata network targets', () => {
+ expect(classifyNetworkTarget('https://provider.example/v1')).toBe('public');
+ expect(classifyNetworkTarget('http://localhost:8317')).toBe('loopback');
+ expect(classifyNetworkTarget('http://127.0.0.1:8317')).toBe('loopback');
+ expect(classifyNetworkTarget('http://[::1]:8317')).toBe('loopback');
+ expect(classifyNetworkTarget('http://10.0.0.5:8080')).toBe('private');
+ expect(classifyNetworkTarget('http://172.16.4.5:8080')).toBe('private');
+ expect(classifyNetworkTarget('http://192.168.1.50:8080')).toBe('private');
+ expect(classifyNetworkTarget('http://169.254.1.10:8080')).toBe('link-local');
+ expect(classifyNetworkTarget('http://169.254.169.254/latest')).toBe('metadata');
+ expect(classifyNetworkTarget('http://metadata.google.internal')).toBe('metadata');
+ });
+
+ it('requires explicit confirmation for private endpoint probes', async () => {
+ const { restore } = installFakeFetch(() => {
+ throw new Error('fetch should not be called');
+ });
+ try {
+ await expect(
+ handleConfigV1TestEndpoint({
+ wire: 'openai-chat',
+ baseUrl: 'http://127.0.0.1:8317/v1',
+ apiKey: 'sk-test',
+ }),
+ ).resolves.toEqual({
+ ok: false,
+ error: 'private-network-confirmation-required',
+ message: 'Private or local network provider URLs require explicit confirmation.',
+ });
+ } finally {
+ restore();
+ }
+ });
+
+ it('allows private endpoint probes after explicit confirmation', async () => {
+ const { restore } = installFakeFetch(() => ({
+ status: 200,
+ body: { data: [{ id: 'local' }] },
+ }));
+ try {
+ await expect(
+ handleConfigV1TestEndpoint({
+ wire: 'openai-chat',
+ baseUrl: 'http://127.0.0.1:8317/v1',
+ apiKey: 'sk-test',
+ allowPrivateNetwork: true,
+ }),
+ ).resolves.toEqual({ ok: true, modelCount: 1, models: ['local'] });
+ } finally {
+ restore();
+ }
+ });
+
+ it('blocks metadata endpoint probes even with private-network confirmation', async () => {
+ const { restore } = installFakeFetch(() => {
+ throw new Error('fetch should not be called');
+ });
+ try {
+ await expect(
+ handleConfigV1TestEndpoint({
+ wire: 'openai-chat',
+ baseUrl: 'http://169.254.169.254/latest',
+ apiKey: 'sk-test',
+ allowPrivateNetwork: true,
+ }),
+ ).resolves.toEqual({
+ ok: false,
+ error: 'blocked-network-target',
+ message: 'Metadata service endpoints cannot be used as model provider base URLs.',
+ });
+ } finally {
+ restore();
+ }
+ });
+
it('rejects malformed baseUrl before attempting fetch', async () => {
const { restore } = installFakeFetch(() => {
throw new Error('fetch should not be called');
diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts
index ccc42755..1852ed21 100644
--- a/apps/desktop/src/main/connection-ipc.ts
+++ b/apps/desktop/src/main/connection-ipc.ts
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
+import { isIP } from 'node:net';
import {
BUILTIN_PROVIDERS,
CodesignError,
@@ -41,7 +42,13 @@ interface ModelsListPayloadV1 {
const CONNECTION_TEST_FIELDS = ['provider', 'apiKey', 'baseUrl'] as const;
const MODELS_LIST_FIELDS = ['provider', 'apiKey', 'baseUrl'] as const;
-const TEST_ENDPOINT_FIELDS = ['wire', 'baseUrl', 'apiKey', 'httpHeaders'] as const;
+const TEST_ENDPOINT_FIELDS = [
+ 'wire',
+ 'baseUrl',
+ 'apiKey',
+ 'httpHeaders',
+ 'allowPrivateNetwork',
+] as const;
function assertKnownFields(
record: Record,
@@ -177,6 +184,62 @@ function parseHttpBaseUrl(value: unknown, field: string): string {
return trimmed;
}
+export type NetworkTargetClass = 'public' | 'loopback' | 'private' | 'link-local' | 'metadata';
+
+function ipv4ToNumber(ip: string): number | null {
+ const parts = ip.split('.').map((part) => Number(part));
+ if (
+ parts.length !== 4 ||
+ parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
+ ) {
+ return null;
+ }
+ const [a, b, c, d] = parts as [number, number, number, number];
+ return ((a << 24) >>> 0) + (b << 16) + (c << 8) + d;
+}
+
+function inIpv4Range(ip: string, base: string, bits: number): boolean {
+ const value = ipv4ToNumber(ip);
+ const baseValue = ipv4ToNumber(base);
+ if (value === null || baseValue === null) return false;
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (value & mask) === (baseValue & mask);
+}
+
+export function classifyNetworkTarget(rawBaseUrl: string): NetworkTargetClass {
+ let parsed: URL;
+ try {
+ parsed = new URL(rawBaseUrl);
+ } catch {
+ return 'public';
+ }
+ const host = parsed.hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
+ if (
+ host === 'metadata.google.internal' ||
+ host === 'metadata' ||
+ host === '169.254.169.254' ||
+ host === 'fd00:ec2::254'
+ ) {
+ return 'metadata';
+ }
+ if (host === 'localhost') return 'loopback';
+ const family = isIP(host);
+ if (family === 4) {
+ if (inIpv4Range(host, '127.0.0.0', 8)) return 'loopback';
+ if (inIpv4Range(host, '10.0.0.0', 8)) return 'private';
+ if (inIpv4Range(host, '172.16.0.0', 12)) return 'private';
+ if (inIpv4Range(host, '192.168.0.0', 16)) return 'private';
+ if (inIpv4Range(host, '169.254.0.0', 16)) return 'link-local';
+ return 'public';
+ }
+ if (family === 6) {
+ if (host === '::1') return 'loopback';
+ if (host.startsWith('fe80:')) return 'link-local';
+ if (host.startsWith('fc') || host.startsWith('fd')) return 'private';
+ }
+ return 'public';
+}
+
// ---------------------------------------------------------------------------
// Models endpoint construction
// ---------------------------------------------------------------------------
@@ -978,6 +1041,22 @@ export async function handleConfigV1TestEndpoint(raw: unknown): Promise;
+ allowPrivateNetwork?: boolean;
}
export type TestEndpointResponse =
@@ -1172,6 +1252,12 @@ function parseTestEndpointPayload(raw: unknown): TestEndpointPayload {
baseUrl: parseHttpBaseUrl(baseUrl, 'baseUrl'),
apiKey: trimmedApiKey,
};
+ if (r['allowPrivateNetwork'] !== undefined) {
+ if (typeof r['allowPrivateNetwork'] !== 'boolean') {
+ throw new CodesignError('allowPrivateNetwork must be a boolean', ERROR_CODES.IPC_BAD_INPUT);
+ }
+ out.allowPrivateNetwork = r['allowPrivateNetwork'];
+ }
const headers = parseTestEndpointHttpHeaders(r['httpHeaders']);
if (headers !== undefined) out.httpHeaders = headers;
return out;
diff --git a/apps/desktop/src/main/diagnostics-ipc.test.ts b/apps/desktop/src/main/diagnostics-ipc.test.ts
index e827ca97..84cab77c 100644
--- a/apps/desktop/src/main/diagnostics-ipc.test.ts
+++ b/apps/desktop/src/main/diagnostics-ipc.test.ts
@@ -672,9 +672,9 @@ describe('redactSensitiveTomlFields', () => {
it('masks the ciphertext field used by this codebase to persist secrets', () => {
// Reproduces the real bundle leak a user reported on 2026-04-22: the
// `[secrets.*] ciphertext = "..."` field was slipping through because
- // it wasn't on the field allowlist. "plain:" is the dev-mode
- // pass-through encoding (see keychain.ts), so the raw token is right
- // there in the exported zip.
+ // it wasn't on the field allowlist. Modern rows use safeStorage-backed
+ // `safe:`, but fallback and legacy config rows can still contain
+ // `plain:`, so the raw token must never appear in the exported zip.
const input = [
'[secrets.claude-code-imported]',
'ciphertext = "plain:another-your-anthropic-auth-token"',
diff --git a/apps/desktop/src/main/diagnostics/redact.ts b/apps/desktop/src/main/diagnostics/redact.ts
index 245f8a62..5ee87e18 100644
--- a/apps/desktop/src/main/diagnostics/redact.ts
+++ b/apps/desktop/src/main/diagnostics/redact.ts
@@ -66,10 +66,10 @@ export function redactForIssueUrl(
/** Mask the VALUE of any TOML line whose key looks sensitive, regardless of
* the value's format. Google (AIzaSy...), Azure base64, DeepSeek, and future
* bearer tokens all slip past format-based regexes. The `ciphertext` field
- * is this codebase's specific storage slot for persisted secrets (safeStorage
- * ciphertext, or in migrated/dev paths a literal plaintext token prefixed
- * `plain:`) — redact unconditionally. `mask` is the user-visible display
- * form and already pre-obscured, so it's intentionally NOT on this list. */
+ * is this codebase's specific storage slot for persisted secrets (`safe:`
+ * safeStorage ciphertext, legacy safeStorage rows, or fallback `plain:`
+ * tokens) — redact unconditionally. `mask` is the user-visible display form
+ * and already pre-obscured, so it's intentionally NOT on this list. */
export function redactSensitiveTomlFields(s: string): string {
return s.replace(
/^(\s*(?:api_?key|token|bearer|secret|access_?token|refresh_?token|password|ciphertext|auth_?token|credential)\s*=\s*)"[^"]*"/gim,
diff --git a/apps/desktop/src/main/keychain.test.ts b/apps/desktop/src/main/keychain.test.ts
index 77e3441c..1e814bbc 100644
--- a/apps/desktop/src/main/keychain.test.ts
+++ b/apps/desktop/src/main/keychain.test.ts
@@ -1,23 +1,26 @@
import { CodesignError, ERROR_CODES, hydrateConfig } from '@open-codesign/shared';
import { describe, expect, it, vi } from 'vitest';
+const loggerMock = vi.hoisted(() => ({
+ warn: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+}));
+
vi.mock('./electron-runtime', () => ({
safeStorage: {
isEncryptionAvailable: vi.fn(() => true),
+ encryptString: vi.fn((s: string) => Buffer.from(`encrypted:${s}`, 'utf8')),
decryptString: vi.fn(() => ''),
},
}));
vi.mock('./logger', () => ({
- getLogger: () => ({
- warn: vi.fn(),
- info: vi.fn(),
- error: vi.fn(),
- }),
+ getLogger: () => loggerMock,
}));
import { safeStorage } from './electron-runtime';
-import { decryptSecret, migrateSecrets } from './keychain';
+import { decryptSecret, encryptSecret, migrateSecrets } from './keychain';
function expectKeychainEmpty(fn: () => unknown): void {
try {
@@ -31,6 +34,29 @@ function expectKeychainEmpty(fn: () => unknown): void {
}
describe('decryptSecret', () => {
+ it('encrypts new secrets with safeStorage when available', () => {
+ const stored = encryptSecret('sk-test-secret');
+ expect(stored).toBe(`safe:${Buffer.from('encrypted:sk-test-secret').toString('base64')}`);
+ });
+
+ it('falls back to plaintext rows when encryption is unavailable', () => {
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValueOnce(false);
+ expect(encryptSecret('sk-test-secret')).toBe('plain:sk-test-secret');
+ expect(loggerMock.warn).toHaveBeenCalledWith(
+ 'keychain.safeStorage.unavailable_plaintext_fallback',
+ );
+ });
+
+ it('reads existing plaintext secret rows', () => {
+ expect(decryptSecret('plain:sk-test-secret')).toBe('sk-test-secret');
+ });
+
+ it('reads new encrypted secret rows', () => {
+ vi.mocked(safeStorage.decryptString).mockReturnValueOnce('sk-test-secret');
+ const stored = `safe:${Buffer.from('ciphertext').toString('base64')}`;
+ expect(decryptSecret(stored)).toBe('sk-test-secret');
+ });
+
it('rejects plaintext secret rows that decrypt to an empty string', () => {
expectKeychainEmpty(() => decryptSecret('plain:'));
});
@@ -41,6 +67,57 @@ describe('decryptSecret', () => {
});
describe('migrateSecrets', () => {
+ it('migrates plaintext rows to encrypted rows when safeStorage is available', () => {
+ const cfg = hydrateConfig({
+ version: 3,
+ activeProvider: 'openai',
+ activeModel: 'gpt-5.4',
+ providers: {
+ openai: {
+ id: 'openai',
+ name: 'OpenAI',
+ builtin: true,
+ wire: 'openai-chat',
+ baseUrl: 'https://api.openai.com/v1',
+ defaultModel: 'gpt-5.4',
+ },
+ },
+ secrets: { openai: { ciphertext: 'plain:sk-test-secret', mask: '' } },
+ });
+
+ const migrated = migrateSecrets(cfg);
+ expect(migrated.changed).toBe(true);
+ expect(migrated.config.secrets['openai']?.ciphertext).toBe(
+ `safe:${Buffer.from('encrypted:sk-test-secret').toString('base64')}`,
+ );
+ expect(migrated.config.secrets['openai']?.mask).toBe('sk-***cret');
+ });
+
+ it('keeps plaintext rows when safeStorage is unavailable', () => {
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
+ const cfg = hydrateConfig({
+ version: 3,
+ activeProvider: 'openai',
+ activeModel: 'gpt-5.4',
+ providers: {
+ openai: {
+ id: 'openai',
+ name: 'OpenAI',
+ builtin: true,
+ wire: 'openai-chat',
+ baseUrl: 'https://api.openai.com/v1',
+ defaultModel: 'gpt-5.4',
+ },
+ },
+ secrets: { openai: { ciphertext: 'plain:sk-test-secret', mask: '' } },
+ });
+
+ const migrated = migrateSecrets(cfg);
+ expect(migrated.changed).toBe(true);
+ expect(migrated.config.secrets['openai']?.ciphertext).toBe('plain:sk-test-secret');
+ vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
+ });
+
it('rejects legacy secret rows that decrypt to an empty string', () => {
vi.mocked(safeStorage.decryptString).mockReturnValueOnce('');
const cfg = hydrateConfig({
diff --git a/apps/desktop/src/main/keychain.ts b/apps/desktop/src/main/keychain.ts
index dc03ddc2..23d296bc 100644
--- a/apps/desktop/src/main/keychain.ts
+++ b/apps/desktop/src/main/keychain.ts
@@ -1,26 +1,28 @@
import { CodesignError, type Config, ERROR_CODES, type SecretRef } from '@open-codesign/shared';
import { safeStorage } from './electron-runtime';
+import { getLogger } from './logger';
-/**
- * Secret storage is now plaintext-in-config.toml (mode 0600), matching
- * Claude Code / Codex / gh / aws / gcloud. safeStorage was removed because
- * unsigned macOS builds triggered a system keychain password prompt on
- * every decrypt — real UX tax for no real security gain (an attacker with
- * filesystem access could read the plaintext ciphertext either way).
- *
- * The file name stays `keychain.ts` to minimise churn across importers;
- * the module is now really a plaintext passthrough with one-shot legacy
- * migration (safeStorage ciphertext → plaintext on first boot after
- * upgrade).
- */
-
-/** Prefix that marks a new-format (plaintext) stored secret. */
+const ENCRYPTED_PREFIX = 'safe:';
const PLAIN_PREFIX = 'plain:';
+const logger = getLogger('keychain');
+
+let warnedPlaintextFallback = false;
+
+function warnPlaintextFallback(): void {
+ if (warnedPlaintextFallback) return;
+ warnedPlaintextFallback = true;
+ logger.warn('keychain.safeStorage.unavailable_plaintext_fallback');
+}
export function encryptSecret(plaintext: string): string {
if (plaintext.length === 0) {
throw new CodesignError('Cannot store empty secret', ERROR_CODES.KEYCHAIN_EMPTY_INPUT);
}
+ if (safeStorage.isEncryptionAvailable()) {
+ const ciphertext = safeStorage.encryptString(plaintext).toString('base64');
+ return `${ENCRYPTED_PREFIX}${ciphertext}`;
+ }
+ warnPlaintextFallback();
return `${PLAIN_PREFIX}${plaintext}`;
}
@@ -28,27 +30,21 @@ export function decryptSecret(stored: string): string {
if (stored.length === 0) {
throw new CodesignError('Cannot read empty secret', ERROR_CODES.KEYCHAIN_EMPTY_INPUT);
}
- const plaintext = stored.startsWith(PLAIN_PREFIX)
- ? stored.slice(PLAIN_PREFIX.length)
- : decryptLegacy(stored);
+ const plaintext = stored.startsWith(ENCRYPTED_PREFIX)
+ ? decryptSafeStorage(stored.slice(ENCRYPTED_PREFIX.length), 'encrypted')
+ : stored.startsWith(PLAIN_PREFIX)
+ ? stored.slice(PLAIN_PREFIX.length)
+ : decryptSafeStorage(stored, 'legacy');
if (plaintext.length === 0) {
throw new CodesignError('Cannot read empty secret', ERROR_CODES.KEYCHAIN_EMPTY_INPUT);
}
return plaintext;
}
-/**
- * Legacy safeStorage recovery. Invoked only for secrets written by older
- * app versions that encrypted via Electron's `safeStorage`. On macOS this
- * will prompt for the keychain password the FIRST time it's called (and
- * only then — subsequent calls in the same process are served from
- * safeStorage's in-process key cache). After `migrateSecrets` rewrites the
- * config, this path is never hit again.
- */
-function decryptLegacy(base64: string): string {
+function decryptSafeStorage(base64: string, format: 'encrypted' | 'legacy'): string {
if (!safeStorage.isEncryptionAvailable()) {
throw new CodesignError(
- 'A legacy encrypted API key was found but the OS keychain is unavailable. Please re-enter your API key in Settings.',
+ `A ${format} API key was found but the OS keychain is unavailable. Please re-enter your API key in Settings.`,
ERROR_CODES.KEYCHAIN_UNAVAILABLE,
);
}
@@ -56,7 +52,7 @@ function decryptLegacy(base64: string): string {
return safeStorage.decryptString(Buffer.from(base64, 'base64'));
} catch (err) {
throw new CodesignError(
- 'Failed to decrypt a legacy API key. Please re-enter your API key in Settings.',
+ `Failed to decrypt a ${format} API key. Please re-enter your API key in Settings.`,
ERROR_CODES.KEYCHAIN_UNAVAILABLE,
{ cause: err },
);
@@ -77,28 +73,17 @@ export function buildSecretRef(plaintext: string): SecretRef {
};
}
-/**
- * One-shot migration run on boot:
- * 1. Any secret stored in legacy safeStorage base64 format → decrypt
- * once (last keychain prompt ever) → rewrite as `plain:`.
- * 2. Any plaintext secret missing its display `mask` → fill it.
- *
- * Idempotent. Decrypt/empty failures are surfaced immediately so the app does
- * not boot with a silently corrupt credential state.
- */
-/** Per-entry migration step: either returns a replacement SecretRef (when
- * the row is legacy ciphertext or is missing its display mask), or null
- * when the row is already fully up-to-date and should be left untouched.
- */
function migrateSecretRef(ref: SecretRef): SecretRef | null {
- const isLegacy = !ref.ciphertext.startsWith(PLAIN_PREFIX);
+ const isEncrypted = ref.ciphertext.startsWith(ENCRYPTED_PREFIX);
const needsMask = ref.mask === undefined || ref.mask.length === 0;
- if (!isLegacy && !needsMask) return null;
+ if (isEncrypted && !needsMask) return null;
const plaintext = decryptSecret(ref.ciphertext);
+ const nextCiphertext =
+ safeStorage.isEncryptionAvailable() && !isEncrypted ? encryptSecret(plaintext) : ref.ciphertext;
return {
- ciphertext: `${PLAIN_PREFIX}${plaintext}`,
+ ciphertext: nextCiphertext,
mask: maskSecret(plaintext),
};
}
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 95e33553..419f7df5 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -515,6 +515,7 @@ const api = {
baseUrl: string;
apiKey: string;
httpHeaders?: Record;
+ allowPrivateNetwork?: 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.test.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx
index 7698ad33..bdf49428 100644
--- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx
+++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.test.tsx
@@ -1,6 +1,6 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
-import { AddCustomProviderModal } from './AddCustomProviderModal';
+import { AddCustomProviderModal, buildEndpointDiscoveryPayload } from './AddCustomProviderModal';
vi.mock('@open-codesign/i18n', () => ({
useT: () => (key: string) => key,
@@ -14,6 +14,7 @@ describe('AddCustomProviderModal', () => {
expect(html).toContain('settings.providers.custom.compatibilityHintTitle');
expect(html).toContain('settings.providers.custom.compatibilityHintBody');
+ expect(html).toContain('settings.providers.custom.allowPrivateNetwork');
});
it('hides the compatibility warning when editing a locked builtin endpoint', () => {
@@ -36,4 +37,13 @@ describe('AddCustomProviderModal', () => {
expect(html).not.toContain('settings.providers.custom.compatibilityHintTitle');
expect(html).not.toContain('settings.providers.custom.compatibilityHintBody');
});
+
+ it('builds endpoint discovery payloads from the latest private-network opt-in value', () => {
+ expect(buildEndpointDiscoveryPayload('openai-chat', ' http://127.0.0.1:8317 ', true)).toEqual({
+ wire: 'openai-chat',
+ baseUrl: 'http://127.0.0.1:8317',
+ apiKey: '',
+ allowPrivateNetwork: true,
+ });
+ });
});
diff --git a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx
index 4cf302d5..8adf483a 100644
--- a/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx
+++ b/apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx
@@ -70,6 +70,24 @@ function pickBestModel(models: string[]): string {
return models[0] ?? '';
}
+export function buildEndpointDiscoveryPayload(
+ wire: WireApi,
+ baseUrl: string,
+ allowPrivateNetwork: boolean,
+): {
+ wire: WireApi;
+ baseUrl: string;
+ apiKey: string;
+ allowPrivateNetwork: boolean;
+} {
+ return {
+ wire,
+ baseUrl: baseUrl.trim(),
+ apiKey: '',
+ allowPrivateNetwork,
+ };
+}
+
/**
* Minimal Custom Provider form — wire-agnostic endpoint onboarding.
* Deliberately barebones (native form + FormData-ish accessors, no schema),
@@ -100,6 +118,7 @@ export function AddCustomProviderModal({
const [test, setTest] = useState({ kind: 'idle' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
+ const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(false);
const [discovery, setDiscovery] = useState({ kind: 'idle' });
// When true, user explicitly chose to type a model name instead of picking from the dropdown.
@@ -110,7 +129,11 @@ export function AddCustomProviderModal({
const debounceTimer = useRef | null>(null);
const discoverySeq = useRef(0);
- function scheduleDiscovery(currentBaseUrl: string, currentWire: WireApi) {
+ function scheduleDiscovery(
+ currentBaseUrl: string,
+ currentWire: WireApi,
+ privateNetworkAllowed = allowPrivateNetwork,
+ ) {
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
if (!currentBaseUrl.trim().match(/^https?:\/\//)) {
discoverySeq.current += 1;
@@ -118,20 +141,22 @@ export function AddCustomProviderModal({
return;
}
debounceTimer.current = setTimeout(() => {
- void runDiscovery(currentBaseUrl, currentWire);
+ void runDiscovery(currentBaseUrl, currentWire, privateNetworkAllowed);
}, 500);
}
- async function runDiscovery(currentBaseUrl: string, currentWire: WireApi) {
+ async function runDiscovery(
+ currentBaseUrl: string,
+ currentWire: WireApi,
+ privateNetworkAllowed = allowPrivateNetwork,
+ ) {
if (!window.codesign?.config) return;
const seq = ++discoverySeq.current;
setDiscovery({ kind: 'discovering' });
try {
- const res = await window.codesign.config.testEndpoint({
- wire: currentWire,
- baseUrl: currentBaseUrl.trim(),
- apiKey: '',
- });
+ const res = await window.codesign.config.testEndpoint(
+ buildEndpointDiscoveryPayload(currentWire, currentBaseUrl, privateNetworkAllowed),
+ );
if (seq !== discoverySeq.current) return;
if (res.ok && res.models.length > 0) {
setDiscovery({ kind: 'found', models: res.models });
@@ -183,6 +208,7 @@ export function AddCustomProviderModal({
wire,
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
+ allowPrivateNetwork,
});
if (res.ok) setTest({ kind: 'ok', modelCount: res.modelCount });
else setTest({ kind: 'error', message: res.message });
@@ -337,6 +363,27 @@ export function AddCustomProviderModal({
)}
+ {!lockEndpoint && (
+
+ )}
diff --git a/apps/desktop/src/renderer/src/components/AskModal.test.ts b/apps/desktop/src/renderer/src/components/AskModal.test.ts
index ed5b5d7c..9409a4f2 100644
--- a/apps/desktop/src/renderer/src/components/AskModal.test.ts
+++ b/apps/desktop/src/renderer/src/components/AskModal.test.ts
@@ -1,6 +1,8 @@
+// @vitest-environment happy-dom
+
import { describe, expect, it } from 'vitest';
import type { AskRequest } from '../../../preload/index';
-import { advanceAskQueue, enqueueAskRequest } from './AskModal';
+import { advanceAskQueue, enqueueAskRequest, sanitizeInlineSvg } from './AskModal';
const request = (requestId: string): AskRequest => ({
requestId,
@@ -10,6 +12,34 @@ const request = (requestId: string): AskRequest => ({
},
});
+describe('sanitizeInlineSvg', () => {
+ it('keeps inert SVG presentation markup', () => {
+ const out = sanitizeInlineSvg(
+ '',
+ );
+
+ expect(out).toContain('