Skip to content

Commit bf86fea

Browse files
gandrenacciclaude
andauthored
feat(desktop): add HTTP proxy setting in advanced preferences (#350)
## Summary - Adds a new `proxyUrl` preference in Settings → Advanced. The URL is applied to Chromium's network stack (`session.defaultSession.setProxy`) and to Node's `HTTP_PROXY` / `HTTPS_PROXY` env vars, so both renderer fetches and main-process outbound traffic (LLM APIs, update feed) route through the configured proxy. - Applied immediately on save and at boot — no restart needed. Empty string disables the proxy. - Persisted in `~/.config/open-codesign/preferences.json` under a bumped `schemaVersion` (8 → 9). Defaults to `''`, so existing installs are unaffected. - i18n strings added for en / es / pt-BR / zh-CN. ## Four principles - **Compatibility** ✅ — additive field, defaults to empty, older preferences upgrade cleanly via the existing migration path. - **Upgradeability** ✅ — `schemaVersion` bumped to 9; `parsePersistedFile` ignores unknown keys from stale builds. - **No bloat** ✅ — no new dependencies; uses only `electron.session` and Node env vars already in the runtime. - **Elegance** ✅ — single `applyProxyConfig(url)` helper, commit-on-blur input so we don't thrash the file/proxy on every keystroke. ## Test plan - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm test` (1837 tests across workspace — all green; new vitest cases cover proxy round-trip, env-var clear, whitespace trimming, and rejection of non-string `proxyUrl`) - [ ] Manual: set proxy in Settings → Advanced, confirm LLM request routes through it, then clear and confirm direct connection resumes — all without restart ## Notes Reference: maintainer green-light on the proxy-settings discussion. Changeset included (`.changeset/proxy-settings.md`): `@open-codesign/desktop` minor, `@open-codesign/i18n` patch. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4fcbbf8 commit bf86fea

11 files changed

Lines changed: 229 additions & 7 deletions

File tree

.changeset/proxy-settings.md

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/i18n": patch
4+
---
5+
6+
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.

apps/desktop/src/main/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import { registerMemoryIpc } from './memory-ipc';
2424
import { isTrustedMainWindowNavigationUrl } from './navigation-policy';
2525
import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
2626
import { isAllowedExternalUrl } from './open-external';
27-
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
27+
import {
28+
applyProxyConfig,
29+
readPersisted as readPreferences,
30+
registerPreferencesIpc,
31+
} from './preferences-ipc';
2832
import { cleanupStaleTmps } from './reported-fingerprints';
2933
import { type Database, pruneDiagnosticEvents, safeInitSnapshotsDb } from './snapshots-db';
3034
import {
@@ -232,6 +236,16 @@ if (!IS_VITEST) {
232236
const aborted = await maybeAbortIfRunningFromDmg();
233237
if (aborted) return;
234238
await loadConfigOnBoot();
239+
// Apply any user-configured outbound proxy to Chromium + Node before
240+
// anything reaches out to provider endpoints or the update feed.
241+
try {
242+
const prefs = await readPreferences();
243+
await applyProxyConfig(prefs.proxyUrl);
244+
} catch (err) {
245+
getLogger('main:boot').warn('preferences.proxy.apply.boot.fail', {
246+
message: err instanceof Error ? err.message : String(err),
247+
});
248+
}
235249
// Seed `<userData>/templates/` from the bundled resources if it does
236250
// not already exist. After the first boot the user owns the tree —
237251
// edits to scaffolds, skills, brand-refs, frames, or design-skills

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

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
44
import { beforeEach, describe, expect, it, vi } from 'vitest';
55

66
// Mock electron and logger before importing the module under test.
7+
const { setProxyMock } = vi.hoisted(() => ({
8+
setProxyMock: vi.fn<(config: { proxyRules: string }) => Promise<void>>(async () => {}),
9+
}));
710
vi.mock('electron', () => ({
811
ipcMain: { handle: vi.fn() },
12+
session: {
13+
defaultSession: {
14+
setProxy: (config: { proxyRules: string }) => setProxyMock(config),
15+
},
16+
},
917
}));
1018

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

37-
import { readPersisted, registerPreferencesIpc } from './preferences-ipc';
45+
import { applyProxyConfig, readPersisted, registerPreferencesIpc } from './preferences-ipc';
3846

3947
describe('readPersisted()', () => {
4048
beforeEach(() => {
@@ -57,6 +65,7 @@ describe('readPersisted()', () => {
5765
memoryEnabled: true,
5866
workspaceMemoryAutoUpdate: true,
5967
userMemoryAutoUpdate: false,
68+
proxyUrl: '',
6069
});
6170
});
6271

@@ -232,7 +241,7 @@ describe('readPersisted()', () => {
232241
schemaVersion: number;
233242
diagnosticsLastReadTs: number;
234243
};
235-
expect(written.schemaVersion).toBe(8);
244+
expect(written.schemaVersion).toBe(9);
236245
expect(written.diagnosticsLastReadTs).toBe(result.diagnosticsLastReadTs);
237246
expect(written.diagnosticsLastReadTs).toBeGreaterThanOrEqual(before);
238247
expect(written.diagnosticsLastReadTs).toBeLessThanOrEqual(after);
@@ -352,9 +361,92 @@ describe('preferences memory schema fields', () => {
352361
workspaceMemoryAutoUpdate: boolean;
353362
userMemoryAutoUpdate: boolean;
354363
};
355-
expect(written.schemaVersion).toBe(8);
364+
expect(written.schemaVersion).toBe(9);
356365
expect(written.memoryEnabled).toBe(false);
357366
expect(written.workspaceMemoryAutoUpdate).toBe(false);
358367
expect(written.userMemoryAutoUpdate).toBe(true);
359368
});
369+
370+
it('round-trips proxyUrl through preferences:v1:update and re-applies the proxy', async () => {
371+
readFileMock.mockResolvedValueOnce(
372+
JSON.stringify({
373+
schemaVersion: 9,
374+
updateChannel: 'stable',
375+
generationTimeoutSec: 1200,
376+
checkForUpdatesOnStartup: false,
377+
dismissedUpdateVersion: '',
378+
diagnosticsLastReadTs: 1,
379+
memoryEnabled: true,
380+
workspaceMemoryAutoUpdate: true,
381+
userMemoryAutoUpdate: false,
382+
proxyUrl: '',
383+
}),
384+
);
385+
setProxyMock.mockClear();
386+
const updated = await (
387+
handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>
388+
)(null, { proxyUrl: 'http://127.0.0.1:7890' });
389+
390+
expect((updated as { proxyUrl: string }).proxyUrl).toBe('http://127.0.0.1:7890');
391+
const lastCall = writeFileMock.mock.calls.at(-1);
392+
if (!lastCall) throw new Error('writeFile was not called');
393+
const written = JSON.parse(lastCall[1] as string) as { proxyUrl: string };
394+
expect(written.proxyUrl).toBe('http://127.0.0.1:7890');
395+
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://127.0.0.1:7890' });
396+
});
397+
398+
it('rejects non-string proxyUrl updates', async () => {
399+
await expect(
400+
(handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(null, {
401+
proxyUrl: 42,
402+
}),
403+
).rejects.toThrow(/proxyUrl must be a string/);
404+
});
405+
});
406+
407+
describe('applyProxyConfig()', () => {
408+
beforeEach(() => {
409+
setProxyMock.mockClear();
410+
delete process.env['HTTP_PROXY'];
411+
delete process.env['HTTPS_PROXY'];
412+
delete process.env['http_proxy'];
413+
delete process.env['https_proxy'];
414+
});
415+
416+
it('sets HTTP(S)_PROXY env vars and Chromium proxy when a URL is provided', async () => {
417+
await applyProxyConfig('http://10.0.0.1:8080');
418+
expect(process.env['HTTP_PROXY']).toBe('http://10.0.0.1:8080');
419+
expect(process.env['HTTPS_PROXY']).toBe('http://10.0.0.1:8080');
420+
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://10.0.0.1:8080' });
421+
});
422+
423+
it('mirrors the URL into lowercase env var spellings so Node http picks it up', async () => {
424+
// Pre-seed lowercase to a stale value; the upper-only write would have left
425+
// this in place and Node's http module would have preferred it.
426+
process.env['http_proxy'] = 'http://stale-shell-value';
427+
process.env['https_proxy'] = 'http://stale-shell-value';
428+
await applyProxyConfig('http://10.0.0.1:8080');
429+
expect(process.env['http_proxy']).toBe('http://10.0.0.1:8080');
430+
expect(process.env['https_proxy']).toBe('http://10.0.0.1:8080');
431+
});
432+
433+
it('clears env vars (both cases) and Chromium proxy when the URL is empty', async () => {
434+
process.env['HTTP_PROXY'] = 'http://stale';
435+
process.env['HTTPS_PROXY'] = 'http://stale';
436+
process.env['http_proxy'] = 'http://stale';
437+
process.env['https_proxy'] = 'http://stale';
438+
await applyProxyConfig('');
439+
expect(process.env['HTTP_PROXY']).toBeUndefined();
440+
expect(process.env['HTTPS_PROXY']).toBeUndefined();
441+
expect(process.env['http_proxy']).toBeUndefined();
442+
expect(process.env['https_proxy']).toBeUndefined();
443+
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: '' });
444+
});
445+
446+
it('trims surrounding whitespace before applying', async () => {
447+
await applyProxyConfig(' http://10.0.0.1:8080 ');
448+
expect(process.env['HTTP_PROXY']).toBe('http://10.0.0.1:8080');
449+
expect(process.env['http_proxy']).toBe('http://10.0.0.1:8080');
450+
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://10.0.0.1:8080' });
451+
});
360452
});

apps/desktop/src/main/preferences-ipc.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
import { mkdir, readFile, writeFile } from 'node:fs/promises';
1212
import { dirname, join } from 'node:path';
1313
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
14-
import { ipcMain } from 'electron';
14+
import { ipcMain, session } from 'electron';
1515
import { configDir } from './config';
1616
import { getLogger } from './logger';
1717

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

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

4952
interface PreferencesFile extends Preferences {
@@ -63,6 +66,7 @@ const DEFAULTS: Preferences = {
6366
memoryEnabled: true,
6467
workspaceMemoryAutoUpdate: true,
6568
userMemoryAutoUpdate: false,
69+
proxyUrl: '',
6670
};
6771

6872
const PREFERENCE_UPDATE_FIELDS = [
@@ -74,6 +78,7 @@ const PREFERENCE_UPDATE_FIELDS = [
7478
'memoryEnabled',
7579
'workspaceMemoryAutoUpdate',
7680
'userMemoryAutoUpdate',
81+
'proxyUrl',
7782
] as const;
7883

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

146151
function readPersistedString(
147152
r: Record<string, unknown>,
148-
key: 'dismissedUpdateVersion',
153+
key: 'dismissedUpdateVersion' | 'proxyUrl',
149154
defaultValue: string,
150155
): string {
151156
const value = r[key];
@@ -218,6 +223,7 @@ function parsePersistedFile(rawJson: unknown): Preferences {
218223
'userMemoryAutoUpdate',
219224
DEFAULTS.userMemoryAutoUpdate,
220225
),
226+
proxyUrl: readPersistedString(parsed, 'proxyUrl', DEFAULTS.proxyUrl),
221227
};
222228
}
223229

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

344+
function readProxyUrl(r: Record<string, unknown>): string | undefined {
345+
const value = r['proxyUrl'];
346+
if (value === undefined) return undefined;
347+
if (typeof value !== 'string') {
348+
throw new CodesignError('proxyUrl must be a string', ERROR_CODES.IPC_BAD_INPUT);
349+
}
350+
return value;
351+
}
352+
338353
function readDiagnosticsTs(r: Record<string, unknown>): number | undefined {
339354
const value = r['diagnosticsLastReadTs'];
340355
if (value === undefined) return undefined;
@@ -372,9 +387,47 @@ function parsePreferences(raw: unknown): Partial<Preferences> {
372387
out.workspaceMemoryAutoUpdate = workspaceMemoryAutoUpdate;
373388
const userMemoryAutoUpdate = readMemoryAutoUpdate(r, 'userMemoryAutoUpdate');
374389
if (userMemoryAutoUpdate !== undefined) out.userMemoryAutoUpdate = userMemoryAutoUpdate;
390+
const proxyUrl = readProxyUrl(r);
391+
if (proxyUrl !== undefined) out.proxyUrl = proxyUrl;
375392
return out;
376393
}
377394

395+
/**
396+
* Apply the configured proxy to Chromium's network stack and Node's
397+
* HTTP(S)_PROXY env vars so both renderer fetches and main-process outbound
398+
* traffic route through it. Empty `proxyUrl` clears any previously set proxy.
399+
* Safe to call before `session.defaultSession` is ready — the Electron side
400+
* is then skipped (with a warning) and re-applied at boot once the app is
401+
* ready.
402+
*
403+
* Both uppercase and lowercase env var spellings are written: Node's `http`
404+
* module prefers lowercase when both are set, so leaving `http_proxy`
405+
* untouched would let a stale shell value silently override the user's
406+
* in-app choice.
407+
*/
408+
export async function applyProxyConfig(proxyUrl: string): Promise<void> {
409+
const cleanUrl = proxyUrl.trim();
410+
if (cleanUrl.length > 0) {
411+
process.env['HTTP_PROXY'] = cleanUrl;
412+
process.env['HTTPS_PROXY'] = cleanUrl;
413+
process.env['http_proxy'] = cleanUrl;
414+
process.env['https_proxy'] = cleanUrl;
415+
} else {
416+
delete process.env['HTTP_PROXY'];
417+
delete process.env['HTTPS_PROXY'];
418+
delete process.env['http_proxy'];
419+
delete process.env['https_proxy'];
420+
}
421+
if (session?.defaultSession) {
422+
await session.defaultSession.setProxy({ proxyRules: cleanUrl });
423+
} else {
424+
logger.warn('preferences.proxy.apply.noSession', {
425+
message:
426+
'session.defaultSession is unavailable; Chromium-side proxy not applied. Will retry at app.whenReady().',
427+
});
428+
}
429+
}
430+
378431
export function registerPreferencesIpc(): void {
379432
ipcMain.handle('preferences:v1:get', async (): Promise<Preferences> => {
380433
return readPersisted();
@@ -385,6 +438,15 @@ export function registerPreferencesIpc(): void {
385438
const current = await readPersisted();
386439
const next: Preferences = { ...current, ...patch };
387440
await writePersisted(next);
441+
if (patch.proxyUrl !== undefined) {
442+
try {
443+
await applyProxyConfig(next.proxyUrl);
444+
} catch (err) {
445+
logger.warn('preferences.proxy.apply.fail', {
446+
message: err instanceof Error ? err.message : String(err),
447+
});
448+
}
449+
}
388450
return next;
389451
});
390452
}

apps/desktop/src/preload/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export interface Preferences {
254254
memoryEnabled: boolean;
255255
workspaceMemoryAutoUpdate: boolean;
256256
userMemoryAutoUpdate: boolean;
257+
proxyUrl: string;
257258
}
258259

259260
export interface MemoryFileRead {

apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,39 @@ export function resolveTimeoutOptions(currentSec: number): number[] {
2525
return base;
2626
}
2727

28+
/** Commit-on-blur input — avoids saving (and re-applying the proxy) on every
29+
* keystroke while still updating the underlying preference when focus leaves
30+
* the field or the user presses Enter. */
31+
function ProxyUrlInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
32+
const [draft, setDraft] = useState(value);
33+
useEffect(() => {
34+
setDraft(value);
35+
}, [value]);
36+
return (
37+
<input
38+
type="text"
39+
value={draft}
40+
onChange={(e) => setDraft(e.target.value)}
41+
onBlur={() => {
42+
if (draft !== value) onCommit(draft);
43+
}}
44+
onKeyDown={(e) => {
45+
if (e.key === 'Enter') {
46+
e.currentTarget.blur();
47+
} else if (e.key === 'Escape') {
48+
setDraft(value);
49+
e.currentTarget.blur();
50+
}
51+
}}
52+
placeholder="http://127.0.0.1:7890"
53+
spellCheck={false}
54+
autoCapitalize="off"
55+
autoCorrect="off"
56+
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"
57+
/>
58+
);
59+
}
60+
2861
export function AdvancedTab() {
2962
const t = useT();
3063
const pushToast = useCodesignStore((s) => s.pushToast);
@@ -37,6 +70,7 @@ export function AdvancedTab() {
3770
memoryEnabled: true,
3871
workspaceMemoryAutoUpdate: true,
3972
userMemoryAutoUpdate: false,
73+
proxyUrl: '',
4074
});
4175

4276
useEffect(() => {
@@ -119,6 +153,10 @@ export function AdvancedTab() {
119153
/>
120154
</Row>
121155

156+
<Row label={t('settings.advanced.proxy')} hint={t('settings.advanced.proxyHint')}>
157+
<ProxyUrlInput value={prefs.proxyUrl} onCommit={(v) => void updatePref({ proxyUrl: v })} />
158+
</Row>
159+
122160
<Row label={t('settings.advanced.devtools')} hint={t('settings.advanced.devtoolsHint')}>
123161
<button
124162
type="button"

apps/desktop/src/renderer/src/components/settings/MemoryTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const DEFAULT_PREFS: Preferences = {
1414
memoryEnabled: true,
1515
workspaceMemoryAutoUpdate: true,
1616
userMemoryAutoUpdate: false,
17+
proxyUrl: '',
1718
};
1819

1920
export function MemoryTab() {

packages/i18n/src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,8 @@
640640
"timeout": "Generation timeout",
641641
"timeoutHint": "Seconds before a generation request is aborted.",
642642
"timeoutSeconds": "{{value}} s",
643+
"proxy": "HTTP proxy",
644+
"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.",
643645
"devtools": "Developer tools",
644646
"devtoolsHint": "Open the Chromium DevTools panel for the renderer.",
645647
"toggleDevtools": "Toggle DevTools",

0 commit comments

Comments
 (0)