Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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
98 changes: 95 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,92 @@ 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'];
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('mirrors the URL into lowercase env var spellings so Node http picks it up', async () => {
// Pre-seed lowercase to a stale value; the upper-only write would have left
// this in place and Node's http module would have preferred it.
process.env['http_proxy'] = 'http://stale-shell-value';
process.env['https_proxy'] = 'http://stale-shell-value';
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');
});

it('clears env vars (both cases) and Chromium proxy when the URL is empty', async () => {
process.env['HTTP_PROXY'] = 'http://stale';
process.env['HTTPS_PROXY'] = 'http://stale';
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(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(process.env['http_proxy']).toBe('http://10.0.0.1:8080');
expect(setProxyMock).toHaveBeenCalledWith({ proxyRules: 'http://10.0.0.1:8080' });
});
});
68 changes: 65 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,47 @@ 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 (with a warning) and re-applied at boot once the app is
* ready.
*
* Both uppercase and lowercase env var spellings are written: Node's `http`
* module prefers lowercase when both are set, so leaving `http_proxy`
* untouched would let a stale shell value silently override the user's
* in-app choice.
*/
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;
process.env['http_proxy'] = cleanUrl;
process.env['https_proxy'] = cleanUrl;
} else {
delete process.env['HTTP_PROXY'];
delete process.env['HTTPS_PROXY'];
delete process.env['http_proxy'];
delete process.env['https_proxy'];
}
if (session?.defaultSession) {
await session.defaultSession.setProxy({ proxyRules: cleanUrl });
} else {
logger.warn('preferences.proxy.apply.noSession', {
message:
'session.defaultSession is unavailable; Chromium-side proxy not applied. Will retry at app.whenReady().',
});
}
}

export function registerPreferencesIpc(): void {
ipcMain.handle('preferences:v1:get', async (): Promise<Preferences> => {
return readPersisted();
Expand All @@ -385,6 +438,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
Loading
Loading