Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/proxy-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": minor
"@open-codesign/i18n": patch
---

Add an HTTP proxy field to Settings → Advanced. The configured URL is applied to both Chromium's network stack and Node's HTTP(S)_PROXY env vars, takes effect immediately, and persists across restarts.
16 changes: 15 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import { registerMemoryIpc } from './memory-ipc';
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';
import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
import { isAllowedExternalUrl } from './open-external';
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
import {
applyProxyConfig,
readPersisted as readPreferences,
registerPreferencesIpc,
} from './preferences-ipc';
import { cleanupStaleTmps } from './reported-fingerprints';
import { type Database, pruneDiagnosticEvents, safeInitSnapshotsDb } from './snapshots-db';
import {
Expand Down Expand Up @@ -232,6 +236,16 @@ if (!IS_VITEST) {
const aborted = await maybeAbortIfRunningFromDmg();
if (aborted) return;
await loadConfigOnBoot();
// Apply any user-configured outbound proxy to Chromium + Node before
// anything reaches out to provider endpoints or the update feed.
try {
const prefs = await readPreferences();
await applyProxyConfig(prefs.proxyUrl);
} catch (err) {
getLogger('main:boot').warn('preferences.proxy.apply.boot.fail', {
message: err instanceof Error ? err.message : String(err),
});
}
// Seed `<userData>/templates/` from the bundled resources if it does
// not already exist. After the first boot the user owns the tree —
// edits to scaffolds, skills, brand-refs, frames, or design-skills
Expand Down
81 changes: 78 additions & 3 deletions apps/desktop/src/main/preferences-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock electron and logger before importing the module under test.
const { setProxyMock } = vi.hoisted(() => ({
setProxyMock: vi.fn<(config: { proxyRules: string }) => Promise<void>>(async () => {}),
}));
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn() },
session: {
defaultSession: {
setProxy: (config: { proxyRules: string }) => setProxyMock(config),
},
},
}));

vi.mock('electron-log/main', () => ({
Expand Down Expand Up @@ -34,7 +42,7 @@ vi.mock('node:fs/promises', () => ({
mkdir: vi.fn(async () => {}),
}));

import { readPersisted, registerPreferencesIpc } from './preferences-ipc';
import { applyProxyConfig, readPersisted, registerPreferencesIpc } from './preferences-ipc';

describe('readPersisted()', () => {
beforeEach(() => {
Expand All @@ -57,6 +65,7 @@ describe('readPersisted()', () => {
memoryEnabled: true,
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
});
});

Expand Down Expand Up @@ -232,7 +241,7 @@ describe('readPersisted()', () => {
schemaVersion: number;
diagnosticsLastReadTs: number;
};
expect(written.schemaVersion).toBe(8);
expect(written.schemaVersion).toBe(9);
expect(written.diagnosticsLastReadTs).toBe(result.diagnosticsLastReadTs);
expect(written.diagnosticsLastReadTs).toBeGreaterThanOrEqual(before);
expect(written.diagnosticsLastReadTs).toBeLessThanOrEqual(after);
Expand Down Expand Up @@ -352,9 +361,75 @@ describe('preferences memory schema fields', () => {
workspaceMemoryAutoUpdate: boolean;
userMemoryAutoUpdate: boolean;
};
expect(written.schemaVersion).toBe(8);
expect(written.schemaVersion).toBe(9);
expect(written.memoryEnabled).toBe(false);
expect(written.workspaceMemoryAutoUpdate).toBe(false);
expect(written.userMemoryAutoUpdate).toBe(true);
});

it('round-trips proxyUrl through preferences:v1:update and re-applies the proxy', async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({
schemaVersion: 9,
updateChannel: 'stable',
generationTimeoutSec: 1200,
checkForUpdatesOnStartup: false,
dismissedUpdateVersion: '',
diagnosticsLastReadTs: 1,
memoryEnabled: true,
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
}),
);
setProxyMock.mockClear();
const updated = await (
handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>
)(null, { proxyUrl: 'http://127.0.0.1:7890' });

expect((updated as { proxyUrl: string }).proxyUrl).toBe('http://127.0.0.1:7890');
const lastCall = writeFileMock.mock.calls.at(-1);
if (!lastCall) throw new Error('writeFile was not called');
const written = JSON.parse(lastCall[1] as string) as { proxyUrl: string };
expect(written.proxyUrl).toBe('http://127.0.0.1:7890');
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://127.0.0.1:7890' });
});

it('rejects non-string proxyUrl updates', async () => {
await expect(
(handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(null, {
proxyUrl: 42,
}),
).rejects.toThrow(/proxyUrl must be a string/);
});
});

describe('applyProxyConfig()', () => {
beforeEach(() => {
setProxyMock.mockClear();
delete process.env['HTTP_PROXY'];
delete process.env['HTTPS_PROXY'];
});

it('sets HTTP(S)_PROXY env vars and Chromium proxy when a URL is provided', async () => {
await applyProxyConfig('http://10.0.0.1:8080');
expect(process.env['HTTP_PROXY']).toBe('http://10.0.0.1:8080');
expect(process.env['HTTPS_PROXY']).toBe('http://10.0.0.1:8080');
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://10.0.0.1:8080' });
});

it('clears env vars and Chromium proxy when the URL is empty', async () => {
process.env['HTTP_PROXY'] = 'http://stale';
process.env['HTTPS_PROXY'] = 'http://stale';
await applyProxyConfig('');
expect(process.env['HTTP_PROXY']).toBeUndefined();
expect(process.env['HTTPS_PROXY']).toBeUndefined();
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: '' });
});

it('trims surrounding whitespace before applying', async () => {
await applyProxyConfig(' http://10.0.0.1:8080 ');
expect(process.env['HTTP_PROXY']).toBe('http://10.0.0.1:8080');
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://10.0.0.1:8080' });
});
});
53 changes: 50 additions & 3 deletions apps/desktop/src/main/preferences-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
import { ipcMain } from 'electron';
import { ipcMain, session } from 'electron';
import { configDir } from './config';
import { getLogger } from './logger';

const logger = getLogger('preferences-ipc');

const SCHEMA_VERSION = 8;
const SCHEMA_VERSION = 9;
// v1 → v2: raise the abandoned 120s timeout default (which aborted real
// agentic runs mid-loop) to 600s. Values that happen to equal the old
// default are treated as unmigrated defaults, not user intent.
Expand All @@ -44,6 +44,9 @@ export interface Preferences {
memoryEnabled: boolean;
workspaceMemoryAutoUpdate: boolean;
userMemoryAutoUpdate: boolean;
/** HTTP/HTTPS proxy URL applied to Chromium and Node outbound traffic.
* Empty string disables the proxy. */
proxyUrl: string;
}

interface PreferencesFile extends Preferences {
Expand All @@ -63,6 +66,7 @@ const DEFAULTS: Preferences = {
memoryEnabled: true,
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
};

const PREFERENCE_UPDATE_FIELDS = [
Expand All @@ -74,6 +78,7 @@ const PREFERENCE_UPDATE_FIELDS = [
'memoryEnabled',
'workspaceMemoryAutoUpdate',
'userMemoryAutoUpdate',
'proxyUrl',
] as const;

function assertKnownPreferenceFields(r: Record<string, unknown>): void {
Expand Down Expand Up @@ -145,7 +150,7 @@ function readPersistedBoolean(

function readPersistedString(
r: Record<string, unknown>,
key: 'dismissedUpdateVersion',
key: 'dismissedUpdateVersion' | 'proxyUrl',
defaultValue: string,
): string {
const value = r[key];
Expand Down Expand Up @@ -218,6 +223,7 @@ function parsePersistedFile(rawJson: unknown): Preferences {
'userMemoryAutoUpdate',
DEFAULTS.userMemoryAutoUpdate,
),
proxyUrl: readPersistedString(parsed, 'proxyUrl', DEFAULTS.proxyUrl),
};
}

Expand Down Expand Up @@ -335,6 +341,15 @@ function readDismissedVersion(r: Record<string, unknown>): string | undefined {
return value;
}

function readProxyUrl(r: Record<string, unknown>): string | undefined {
const value = r['proxyUrl'];
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw new CodesignError('proxyUrl must be a string', ERROR_CODES.IPC_BAD_INPUT);
}
return value;
}

function readDiagnosticsTs(r: Record<string, unknown>): number | undefined {
const value = r['diagnosticsLastReadTs'];
if (value === undefined) return undefined;
Expand Down Expand Up @@ -372,9 +387,32 @@ function parsePreferences(raw: unknown): Partial<Preferences> {
out.workspaceMemoryAutoUpdate = workspaceMemoryAutoUpdate;
const userMemoryAutoUpdate = readMemoryAutoUpdate(r, 'userMemoryAutoUpdate');
if (userMemoryAutoUpdate !== undefined) out.userMemoryAutoUpdate = userMemoryAutoUpdate;
const proxyUrl = readProxyUrl(r);
if (proxyUrl !== undefined) out.proxyUrl = proxyUrl;
return out;
}

/**
* Apply the configured proxy to Chromium's network stack and Node's
* HTTP(S)_PROXY env vars so both renderer fetches and main-process outbound
* traffic route through it. Empty `proxyUrl` clears any previously set proxy.
* Safe to call before `session.defaultSession` is ready — the Electron side
* is then skipped and re-applied at boot once the app is ready.
*/
export async function applyProxyConfig(proxyUrl: string): Promise<void> {
const cleanUrl = proxyUrl.trim();
if (cleanUrl.length > 0) {
process.env['HTTP_PROXY'] = cleanUrl;
process.env['HTTPS_PROXY'] = cleanUrl;
} else {
delete process.env['HTTP_PROXY'];
delete process.env['HTTPS_PROXY'];
}
if (session?.defaultSession) {
await session.defaultSession.setProxy({ proxyRules: cleanUrl });
}
}

export function registerPreferencesIpc(): void {
ipcMain.handle('preferences:v1:get', async (): Promise<Preferences> => {
return readPersisted();
Expand All @@ -385,6 +423,15 @@ export function registerPreferencesIpc(): void {
const current = await readPersisted();
const next: Preferences = { ...current, ...patch };
await writePersisted(next);
if (patch.proxyUrl !== undefined) {
try {
await applyProxyConfig(next.proxyUrl);
} catch (err) {
logger.warn('preferences.proxy.apply.fail', {
message: err instanceof Error ? err.message : String(err),
});
}
}
return next;
});
}
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export interface Preferences {
memoryEnabled: boolean;
workspaceMemoryAutoUpdate: boolean;
userMemoryAutoUpdate: boolean;
proxyUrl: string;
}

export interface MemoryFileRead {
Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,39 @@ export function resolveTimeoutOptions(currentSec: number): number[] {
return base;
}

/** Commit-on-blur input — avoids saving (and re-applying the proxy) on every
* keystroke while still updating the underlying preference when focus leaves
* the field or the user presses Enter. */
function ProxyUrlInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
return (
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) onCommit(draft);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setDraft(value);
e.currentTarget.blur();
}
}}
placeholder="http://127.0.0.1:7890"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className="h-7 w-full max-w-md px-2.5 bg-[var(--color-background)] border border-[var(--color-border)] rounded-[var(--radius-sm)] text-[var(--text-sm)] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)] transition-colors"
/>
);
}

export function AdvancedTab() {
const t = useT();
const pushToast = useCodesignStore((s) => s.pushToast);
Expand All @@ -37,6 +70,7 @@ export function AdvancedTab() {
memoryEnabled: true,
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
});

useEffect(() => {
Expand Down Expand Up @@ -119,6 +153,10 @@ export function AdvancedTab() {
/>
</Row>

<Row label={t('settings.advanced.proxy')} hint={t('settings.advanced.proxyHint')}>
<ProxyUrlInput value={prefs.proxyUrl} onCommit={(v) => void updatePref({ proxyUrl: v })} />
</Row>

<Row label={t('settings.advanced.devtools')} hint={t('settings.advanced.devtoolsHint')}>
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const DEFAULT_PREFS: Preferences = {
memoryEnabled: true,
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
};

export function MemoryTab() {
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@
"timeout": "Generation timeout",
"timeoutHint": "Seconds before a generation request is aborted.",
"timeoutSeconds": "{{value}} s",
"proxy": "HTTP proxy",
"proxyHint": "Route outbound network and LLM API requests through this proxy (e.g., http://127.0.0.1:7890). Leave blank to disable. Applied immediately — no restart needed.",
"devtools": "Developer tools",
"devtoolsHint": "Open the Chromium DevTools panel for the renderer.",
"toggleDevtools": "Toggle DevTools",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,8 @@
"timeout": "Tiempo de espera de generación",
"timeoutHint": "Segundos antes de que una solicitud de generación se aborte.",
"timeoutSeconds": "{{value}} s",
"proxy": "Proxy HTTP",
"proxyHint": "Enruta el tráfico de red y las solicitudes a la API del LLM a través de este proxy (p. ej., http://127.0.0.1:7890). Deja en blanco para desactivar. Se aplica al instante, sin reinicio.",
"devtools": "Herramientas de desarrollador",
"devtoolsHint": "Abrir el panel de Chromium DevTools para el renderizador.",
"toggleDevtools": "Alternar DevTools",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,8 @@
"timeout": "Tempo limite de geração",
"timeoutHint": "Segundos antes de uma geração ser abortada.",
"timeoutSeconds": "{{value}} s",
"proxy": "Proxy HTTP",
"proxyHint": "Encaminha o tráfego de rede e as chamadas à API do LLM por este proxy (ex.: http://127.0.0.1:7890). Deixe em branco para desativar. Aplicado imediatamente, sem reinício.",
"devtools": "Ferramentas de desenvolvedor",
"devtoolsHint": "Abre o painel DevTools do Chromium para o renderer.",
"toggleDevtools": "Alternar DevTools",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@
"timeout": "生成超时",
"timeoutHint": "超过该秒数后会中止生成请求。",
"timeoutSeconds": "{{value}} 秒",
"proxy": "HTTP 代理",
"proxyHint": "将出站网络与 LLM API 请求通过此代理转发(例如 http://127.0.0.1:7890)。留空表示禁用。即时生效,无需重启。",
"devtools": "开发者工具",
"devtoolsHint": "打开渲染进程的 Chromium DevTools 面板。",
"toggleDevtools": "切换 DevTools",
Expand Down
Loading