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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
"CLAUDE_CLI_PATH",
# Profile's custom config directory (for multi-profile token storage)
"CLAUDE_CONFIG_DIR",
# Custom CA certificate for enterprise proxy SSL (Zscaler, etc.)
"NODE_EXTRA_CA_CERTS",
]


Expand Down
8 changes: 8 additions & 0 deletions apps/frontend/src/main/agent/agent-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ export class AgentProcessManager {
const ghCliEnv = this.detectAndSetCliPath('gh');
const glabCliEnv = this.detectAndSetCliPath('glab');

// Inject custom CA certificate path for enterprise proxy SSL support
const certEnv: Record<string, string> = {};
const appSettingsForCert = readSettingsFile() as Partial<AppSettings> | null;
if (appSettingsForCert?.customCACertPath) {
certEnv['NODE_EXTRA_CA_CERTS'] = appSettingsForCert.customCACertPath;
}

// Profile env is spread last to ensure CLAUDE_CONFIG_DIR and auth vars
// from the active profile always win over extraEnv or augmentedEnv.
const mergedEnv = {
Expand All @@ -237,6 +244,7 @@ export class AgentProcessManager {
...claudeCliEnv,
...ghCliEnv,
...glabCliEnv,
...certEnv,
...extraEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
Expand Down
9 changes: 8 additions & 1 deletion apps/frontend/src/main/ipc-handlers/profile-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ vi.mock('electron', () => ({
}
}));

// Mock settings-utils to avoid electron.app dependency
vi.mock('../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({})),
getSettingsPath: vi.fn(() => '/test/settings.json')
}));

// Mock profile service
vi.mock('../services/profile', () => ({
loadProfilesFile: mockedLoadProfilesFile,
Expand Down Expand Up @@ -244,7 +250,8 @@ describe('profile-handlers - testConnection', () => {
expect(testConnection).toHaveBeenCalledWith(
'https://api.anthropic.com',
'sk-test-key-12chars',
expect.any(AbortSignal)
expect.any(AbortSignal),
undefined
);
});
});
Expand Down
11 changes: 9 additions & 2 deletions apps/frontend/src/main/ipc-handlers/profile-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import type { IPCResult } from '../../shared/types';
import type { AppSettings } from '../../shared/types';
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
import {
loadProfilesFile,
Expand All @@ -25,10 +26,16 @@ import {
testConnection,
discoverModels
} from '../services/profile';
import { readSettingsFile } from '../settings-utils';

// Track active test connection requests for cancellation
const activeTestConnections = new Map<number, AbortController>();

function getCustomCaCertPath(): string | undefined {
const settings = readSettingsFile() as Partial<AppSettings> | null;
return settings?.customCACertPath || undefined;
}

// Track active discover models requests for cancellation
const activeDiscoverModelsRequests = new Map<number, AbortController>();

Expand Down Expand Up @@ -214,7 +221,7 @@ export function registerProfileHandlers(): void {
}

// Call testConnection from service layer with abort signal
const result = await testConnection(baseUrl, apiKey, controller.signal);
const result = await testConnection(baseUrl, apiKey, controller.signal, getCustomCaCertPath());

// Clear timeout on success
clearTimeout(timeoutId);
Expand Down Expand Up @@ -300,7 +307,7 @@ export function registerProfileHandlers(): void {
}

// Call discoverModels from service layer with abort signal
const result = await discoverModels(baseUrl, apiKey, controller.signal);
const result = await discoverModels(baseUrl, apiKey, controller.signal, getCustomCaCertPath());

// Clear timeout on success
clearTimeout(timeoutId);
Expand Down
19 changes: 19 additions & 0 deletions apps/frontend/src/main/ipc-handlers/settings-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,25 @@ export function registerSettingsHandlers(
}
);

ipcMain.handle(
IPC_CHANNELS.DIALOG_SELECT_FILE,
async (_, filters?: { name: string; extensions: string[] }[]): Promise<string | null> => {
const mainWindow = getMainWindow();
if (!mainWindow) return null;

const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }]
});

if (result.canceled || result.filePaths.length === 0) {
return null;
}

return result.filePaths[0];
}
);

ipcMain.handle(
IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER,
async (
Expand Down
97 changes: 93 additions & 4 deletions apps/frontend/src/main/services/profile/profile-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* Uses atomic operations with file locking to prevent TOCTOU race conditions.
*/

import https from 'node:https';
import fs from 'node:fs';
import type { OutgoingHttpHeaders } from 'node:http';
import Anthropic, {
AuthenticationError,
NotFoundError,
Expand All @@ -16,6 +19,88 @@ import Anthropic, {
import { loadProfilesFile, generateProfileId, atomicModifyProfiles } from './profile-manager';
import type { APIProfile, TestConnectionResult, ModelInfo, DiscoverModelsResult } from '@shared/types/profile';

/**
* Creates a custom fetch function that routes HTTPS requests through a
* Node.js https.Agent configured with the provided CA certificate.
* Used to support custom CA certs (e.g., Zscaler, corporate proxies).
*/
function createFetchWithCA(ca: Buffer): typeof globalThis.fetch {
const agent = new https.Agent({ ca });

return async (input, init): Promise<Response> => {
const urlStr = typeof input === 'string' ? input : (input as URL | Request).toString();
const url = new URL(urlStr);
const method = init?.method ?? 'GET';

const rawHeaders: OutgoingHttpHeaders = {};
const initHeaders = init?.headers;
if (initHeaders) {
if (initHeaders instanceof Headers) {
initHeaders.forEach((v, k) => { rawHeaders[k] = v; });
} else if (Array.isArray(initHeaders)) {
for (const [k, v] of initHeaders as [string, string][]) rawHeaders[k] = v;
} else {
Object.assign(rawHeaders, initHeaders);
}
}

return new Promise<Response>((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: Number(url.port) || 443,
path: url.pathname + url.search,
method,
headers: rawHeaders,
agent,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks);
const headers = new Headers();
for (const [k, v] of Object.entries(res.headers)) {
if (v !== undefined) {
(Array.isArray(v) ? v : [v]).forEach((val) => headers.append(k, val));
}
}
resolve(new Response(body, { status: res.statusCode ?? 200, headers }));
});
res.on('error', reject);
}
);
req.on('error', reject);
// Handle all valid BodyInit types: string, Buffer/Uint8Array, URLSearchParams
const body = init?.body;
if (body != null) {
if (typeof body === 'string' || body instanceof Uint8Array) {
req.write(body);
} else if (body instanceof URLSearchParams) {
req.write(body.toString());
}
// ReadableStream is not used by the Anthropic SDK for these endpoints
}
req.end();
});
};
}

/**
* Build a custom fetch for the Anthropic SDK from a CA cert path.
* Returns undefined if no path given or the file cannot be read.
*/
function buildCustomFetch(caCertPath?: string): typeof globalThis.fetch | undefined {
if (!caCertPath) return undefined;
try {
const ca = fs.readFileSync(caCertPath);
return createFetchWithCA(ca);
} catch {
// If the cert file can't be read (missing/unreadable), fall back to default TLS
return undefined;
}
}

/**
* Input type for creating a profile (without id, createdAt, updatedAt)
*/
Expand Down Expand Up @@ -309,7 +394,8 @@ export async function getAPIProfileEnv(): Promise<Record<string, string>> {
export async function testConnection(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
signal?: AbortSignal,
caCertPath?: string
): Promise<TestConnectionResult> {
// Validate API key first (key format doesn't depend on URL normalization)
if (!validateApiKey(apiKey)) {
Expand Down Expand Up @@ -391,12 +477,13 @@ export async function testConnection(
}

try {
// Create Anthropic client with SDK
// Create Anthropic client with SDK, using custom fetch if a CA cert is configured
const client = new Anthropic({
apiKey,
baseURL: normalizedUrl,
timeout: 10000, // 10 seconds
maxRetries: 0, // Disable retries for immediate feedback
fetch: buildCustomFetch(caCertPath),
});

// Make minimal request to test connection (pass signal for cancellation)
Expand Down Expand Up @@ -514,7 +601,8 @@ export async function testConnection(
export async function discoverModels(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
signal?: AbortSignal,
caCertPath?: string
): Promise<DiscoverModelsResult> {
// Validate API key first
if (!validateApiKey(apiKey)) {
Expand Down Expand Up @@ -556,12 +644,13 @@ export async function discoverModels(
}

try {
// Create Anthropic client with SDK
// Create Anthropic client with SDK, using custom fetch if a CA cert is configured
const client = new Anthropic({
apiKey,
baseURL: normalizedUrl,
timeout: 10000, // 10 seconds
maxRetries: 0, // Disable retries for immediate feedback
fetch: buildCustomFetch(caCertPath),
});

// Fetch models with pagination (1000 limit to get all), pass signal for cancellation
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/preload/api/project-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface ProjectAPI {

// Dialog Operations
selectDirectory: () => Promise<string | null>;
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
createProjectFolder: (
location: string,
name: string,
Expand Down Expand Up @@ -219,6 +220,9 @@ export const createProjectAPI = (): ProjectAPI => ({
selectDirectory: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY),

selectFile: (filters?: { name: string; extensions: string[] }[]): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_FILE, filters),

createProjectFolder: (
location: string,
name: string,
Expand Down
31 changes: 31 additions & 0 deletions apps/frontend/src/renderer/components/settings/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { FileText } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Switch } from '../ui/switch';
import { SettingsSection } from './SettingsSection';
Expand Down Expand Up @@ -352,6 +354,35 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General
onChange={(e) => onSettingsChange({ ...settings, autoBuildPath: e.target.value })}
/>
</div>
{/* Custom CA Certificate */}
<div className="space-y-3 pt-4 border-t border-border">
<Label htmlFor="customCACertPath" className="text-sm font-medium text-foreground">{t('general.customCACertPath')}</Label>
<p className="text-sm text-muted-foreground">{t('general.customCACertPathDescription')}</p>
<div className="flex gap-2 max-w-lg">
<Input
id="customCACertPath"
placeholder={t('general.customCACertPathPlaceholder')}
className="flex-1"
value={settings.customCACertPath || ''}
onChange={(e) => onSettingsChange({ ...settings, customCACertPath: e.target.value })}
/>
<Button
variant="outline"
size="icon"
onClick={async () => {
const result = await window.electronAPI.selectFile([
{ name: 'Certificates', extensions: ['pem', 'crt', 'cer'] }
]);
if (result) {
onSettingsChange({ ...settings, customCACertPath: result });
}
}}
aria-label={t('general.customCACertBrowse')}
>
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</SettingsSection>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/lib/mocks/project-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const projectMock = {
return prompt('Enter project path (browser mock):', '/Users/demo/projects/new-project');
},

selectFile: async () => {
return prompt('Enter file path (browser mock):', '/path/to/certificate.pem');
},

createProjectFolder: async (_location: string, name: string, initGit: boolean) => ({
success: true,
data: {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/shared/constants/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export const IPC_CHANNELS = {

// Dialogs
DIALOG_SELECT_DIRECTORY: 'dialog:selectDirectory',
DIALOG_SELECT_FILE: 'dialog:selectFile',
DIALOG_CREATE_PROJECT_FOLDER: 'dialog:createProjectFolder',
DIALOG_GET_DEFAULT_PROJECT_LOCATION: 'dialog:getDefaultProjectLocation',

Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/shared/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@
"autoClaudePath": "Auto Claude Path",
"autoClaudePathDescription": "Relative path to auto-claude directory in projects",
"autoClaudePathPlaceholder": "auto-claude (default)",
"customCACertPath": "Custom CA Certificate",
"customCACertPathDescription": "Path to a custom CA certificate file (.pem/.crt) for SSL connections (e.g., Zscaler, corporate proxies)",
"customCACertPathPlaceholder": "Leave empty to use system defaults",
"customCACertBrowse": "Browse for certificate file",
"autoNameTerminals": "Automatically name terminals",
"autoNameTerminalsDescription": "Use AI to generate descriptive names for terminal tabs based on their activity"
},
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/shared/i18n/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@
"autoClaudePath": "Chemin Auto Claude",
"autoClaudePathDescription": "Chemin relatif vers le répertoire auto-claude dans les projets",
"autoClaudePathPlaceholder": "auto-claude (par défaut)",
"customCACertPath": "Certificat CA personnalisé",
"customCACertPathDescription": "Chemin vers un fichier de certificat CA (.pem/.crt) pour les connexions SSL (ex: Zscaler, proxys d'entreprise)",
"customCACertPathPlaceholder": "Laisser vide pour utiliser les paramètres système",
"customCACertBrowse": "Parcourir pour un fichier de certificat",
"autoNameTerminals": "Nommer automatiquement les terminaux",
"autoNameTerminalsDescription": "Utiliser l'IA pour générer des noms descriptifs pour les onglets de terminal en fonction de leur activité"
},
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ export interface ElectronAPI {

// Dialog operations
selectDirectory: () => Promise<string | null>;
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
createProjectFolder: (location: string, name: string, initGit: boolean) => Promise<IPCResult<CreateProjectFolderResult>>;
getDefaultProjectLocation: () => Promise<string | null>;

Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/shared/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ export interface AppSettings {
sidebarCollapsed?: boolean;
// GPU acceleration for terminal rendering (WebGL)
gpuAcceleration?: GpuAcceleration;
// Custom CA certificate path for enterprise proxy SSL (e.g., Zscaler)
customCACertPath?: string;
}

// GPU acceleration mode for terminal WebGL rendering
Expand Down
Loading
Loading