Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
6 changes: 6 additions & 0 deletions apps/frontend/src/main/agent/agent-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ vi.mock('electron', () => ({
}
}));

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

// Mock cli-tool-manager to avoid blocking tool detection on Windows
vi.mock('../cli-tool-manager', () => ({
getToolInfo: vi.fn((tool: string) => {
Expand Down
17 changes: 17 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,22 @@ 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;
const rawCertPath = appSettingsForCert?.customCACertPath;
const configuredCertPath = typeof rawCertPath === 'string' ? rawCertPath.trim() : undefined;
if (configuredCertPath) {
const resolvedCertPath = path.isAbsolute(configuredCertPath)
? configuredCertPath
: path.resolve(configuredCertPath);
if (existsSync(resolvedCertPath)) {
certEnv['NODE_EXTRA_CA_CERTS'] = resolvedCertPath;
} else {
console.warn('[AgentProcess] customCACertPath not found, skipping NODE_EXTRA_CA_CERTS:', resolvedCertPath);
}
}

// 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 +253,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
150 changes: 146 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,10 @@
* Uses atomic operations with file locking to prevent TOCTOU race conditions.
*/

import http from 'node:http';
import https from 'node:https';
import fs from 'node:fs';
import type { OutgoingHttpHeaders } from 'node:http';
import Anthropic, {
AuthenticationError,
NotFoundError,
Expand All @@ -16,6 +20,116 @@ 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 requests through a Node.js
* http/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 httpsAgent = new https.Agent({ ca });

return async (input, init): Promise<Response> => {
// Derive URL string — use Request.url for Request objects, not toString()
const urlStr =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
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);
}
}

// Abort signal: reject immediately if already aborted
const signal = init?.signal ?? undefined;
if (signal?.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}

return new Promise<Response>((resolve, reject) => {
// Support both HTTP and HTTPS — only inject the custom CA agent for HTTPS
const isHttps = url.protocol === 'https:';
const requestFn = isHttps ? https.request : http.request;
const defaultPort = isHttps ? 443 : 80;

const req = requestFn(
{
hostname: url.hostname,
port: Number(url.port) || defaultPort,
path: url.pathname + url.search,
method,
headers: rawHeaders,
agent: isHttps ? httpsAgent : undefined,
},
(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);
}
);

// Wire abort signal to destroy the request
const onAbort = () => {
req.destroy(new DOMException('Aborted', 'AbortError'));
reject(new DOMException('Aborted', 'AbortError'));
};
signal?.addEventListener('abort', onAbort, { once: true });
req.on('close', () => signal?.removeEventListener('abort', onAbort));

req.on('error', reject);

// Handle all valid BodyInit types the Anthropic SDK sends
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());
}
}
req.end();
});
};
}

/**
* Build a custom fetch for the Anthropic SDK from a CA cert path.
* Throws with a clear message if the cert file cannot be read,
* so misconfiguration surfaces immediately rather than producing
* a misleading "network error".
*/
function buildCustomFetch(caCertPath?: string): typeof globalThis.fetch | undefined {
if (!caCertPath) return undefined;
try {
const ca = fs.readFileSync(caCertPath);
return createFetchWithCA(ca);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read CA certificate at "${caCertPath}": ${msg}`);
}
}

/**
* Input type for creating a profile (without id, createdAt, updatedAt)
*/
Expand Down Expand Up @@ -309,7 +423,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 @@ -390,13 +505,26 @@ export async function testConnection(
};
}

// Build custom fetch before the SDK try-catch so cert errors surface clearly
let customFetch: typeof globalThis.fetch | undefined;
try {
customFetch = buildCustomFetch(caCertPath);
} catch (certErr) {
return {
success: false,
errorType: 'network',
message: certErr instanceof Error ? certErr.message : 'Failed to read CA certificate.'
};
}

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: customFetch,
});

// Make minimal request to test connection (pass signal for cancellation)
Expand Down Expand Up @@ -514,7 +642,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 @@ -555,13 +684,26 @@ export async function discoverModels(
throw error;
}

// Build custom fetch before the SDK try-catch so cert errors surface clearly
let customFetchForDiscover: typeof globalThis.fetch | undefined;
try {
customFetchForDiscover = buildCustomFetch(caCertPath);
} catch (certErr) {
const certError: Error & { errorType?: string } = new Error(
certErr instanceof Error ? certErr.message : 'Failed to read CA certificate.'
);
certError.errorType = 'network';
throw certError;
}

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: customFetchForDiscover,
});

// 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
Loading
Loading