diff --git a/src/components/settings/agents/add-custom-agent-dialog.test.tsx b/src/components/settings/agents/add-custom-agent-dialog.test.tsx index 8140660e8..3dcb0dcdc 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.test.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.test.tsx @@ -49,35 +49,42 @@ describe('validateAgentUrl', () => { const isIos = () => true it('accepts wss:// on non-iOS platforms', () => { - expect(validateAgentUrl('wss://example.com', notIos)).toEqual({ transport: 'websocket' }) + expect(validateAgentUrl('wss://example.com', { isIos: notIos })).toEqual({ transport: 'websocket' }) }) - it('accepts ws:// on non-iOS platforms (LAN/dev use)', () => { - expect(validateAgentUrl('ws://localhost:8080/ws', notIos)).toEqual({ transport: 'websocket' }) + it('rejects ws:// by default (insecure opt-in off)', () => { + const result = validateAgentUrl('ws://localhost:8080/ws', { isIos: notIos }) + expect('error' in result && result.error).toMatch(/insecure|Developer Settings/i) + }) + + it('accepts ws:// when allowInsecure is opted in (local agent)', () => { + expect(validateAgentUrl('ws://localhost:8080/ws', { isIos: notIos, allowInsecure: true })).toEqual({ + transport: 'websocket', + }) }) it('rejects http:// with a clear "WebSocket only" message', () => { - const result = validateAgentUrl('http://example.com/acp', notIos) + const result = validateAgentUrl('http://example.com/acp', { isIos: notIos }) expect('error' in result && result.error).toMatch(/WebSocket|wss:\/\/|ws:\/\//i) }) it('rejects https:// with a clear "WebSocket only" message', () => { - const result = validateAgentUrl('https://example.com/acp', notIos) + const result = validateAgentUrl('https://example.com/acp', { isIos: notIos }) expect('error' in result && result.error).toMatch(/WebSocket|wss:\/\/|ws:\/\//i) }) it('rejects unsupported schemes with a user-facing message', () => { - const result = validateAgentUrl('ftp://example.com', notIos) + const result = validateAgentUrl('ftp://example.com', { isIos: notIos }) expect('error' in result && result.error).toMatch(/WebSocket|wss:\/\/|ws:\/\//i) }) - it('rejects ws:// on Tauri iOS (ATS forbids cleartext)', () => { - const result = validateAgentUrl('ws://example.com', isIos) + it('rejects ws:// on Tauri iOS even when opted in (ATS forbids cleartext)', () => { + const result = validateAgentUrl('ws://example.com', { isIos, allowInsecure: true }) expect('error' in result && result.error).toMatch(/iOS.*secure/i) }) it('still accepts wss:// on Tauri iOS', () => { - expect(validateAgentUrl('wss://example.com', isIos)).toEqual({ transport: 'websocket' }) + expect(validateAgentUrl('wss://example.com', { isIos })).toEqual({ transport: 'websocket' }) }) }) diff --git a/src/components/settings/agents/add-custom-agent-dialog.tsx b/src/components/settings/agents/add-custom-agent-dialog.tsx index b3c66bc9c..8b8f9c471 100644 --- a/src/components/settings/agents/add-custom-agent-dialog.tsx +++ b/src/components/settings/agents/add-custom-agent-dialog.tsx @@ -16,6 +16,7 @@ import { import { Dialog } from '@/components/ui/dialog' import { StatusCard } from '@/components/ui/status-card' import { getPlatform, isTauri } from '@/lib/platform' +import { useLocalSettingsStore } from '@/stores/local-settings-store' import { testAcpConnection as defaultTestAcpConnection } from '@/acp' /** Maps a user-entered URL to the ACP transport flavor we support, or `null` @@ -40,18 +41,30 @@ const defaultIsTauriIOS = (): boolean => isTauri() && getPlatform() === 'ios' /** Pure validation of `url` against the platform's transport rules. Returns * the inferred transport on success, or a user-facing error string. Extracted - * so the test suite can exercise it without rendering the dialog. */ + * so the test suite can exercise it without rendering the dialog. + * + * Cleartext `ws://` is rejected by default — secure `wss://` is required unless + * the user opts in via `allowInsecure` (the "Allow insecure local agents" + * Developer Setting), which exists for connecting to a local agent binary on + * 127.0.0.1. iOS rejects `ws://` regardless (Apple ATS blocks cleartext). */ export const validateAgentUrl = ( url: string, - isIos: () => boolean = defaultIsTauriIOS, + { isIos = defaultIsTauriIOS, allowInsecure = false }: { isIos?: () => boolean; allowInsecure?: boolean } = {}, ): { transport: 'websocket' } | { error: string } => { const transport = inferTransport(url) if (!transport) { - return { error: 'Only WebSocket endpoints are supported (wss:// or ws://)' } + return { error: 'Only WebSocket endpoints are supported (wss://, or ws:// for local agents when enabled)' } } - if (isIos() && new URL(url).protocol === 'ws:') { + const isCleartext = new URL(url).protocol === 'ws:' + if (isCleartext && isIos()) { return { error: 'iOS requires a secure URL (wss://)' } } + if (isCleartext && !allowInsecure) { + return { + error: + 'Insecure ws:// is disabled. Enable “Allow insecure local agents” in Developer Settings to use a local agent.', + } + } return { transport } } @@ -144,11 +157,13 @@ export const AddCustomAgentDialog = ({ testAcpConnection = defaultTestAcpConnection, }: AddCustomAgentDialogProps) => { const [state, dispatch] = useReducer(agentDialogReducer, initialState) + // Opt-in gate: only permit cleartext ws:// when the user has enabled it. + const allowInsecure = useLocalSettingsStore((s) => s.allowInsecureAcp) const trimmedName = state.name.trim() const trimmedUrl = state.url.trim() const trimmedDescription = state.description.trim() - const validation = validateAgentUrl(trimmedUrl, isIos) + const validation = validateAgentUrl(trimmedUrl, { isIos, allowInsecure }) // Surface an invalid-URL error at render time (once the field is non-empty) // so the user sees why Test Connection is unavailable and Add stays gated. const urlError = trimmedUrl.length > 0 && 'error' in validation ? validation.error : null diff --git a/src/settings/dev-settings.tsx b/src/settings/dev-settings.tsx index b1e57d067..1e02d5913 100644 --- a/src/settings/dev-settings.tsx +++ b/src/settings/dev-settings.tsx @@ -19,9 +19,10 @@ export default function DevSettingsPage() { cloudUrl: s.cloudUrl, isNativeFetchEnabled: s.isNativeFetchEnabled, debugPosthog: s.debugPosthog, + allowInsecureAcp: s.allowInsecureAcp, })), ) - const { cloudUrl, isNativeFetchEnabled, debugPosthog } = settings + const { cloudUrl, isNativeFetchEnabled, debugPosthog, allowInsecureAcp } = settings const setLocalSetting = useLocalSettingsStore((s) => s.setLocalSetting) const isModified = (key: K) => settings[key] !== initialLocalSettings[key] @@ -113,6 +114,26 @@ export default function DevSettingsPage() { + + +
+
+ resetSetting('allowInsecureAcp')} + > + Allow insecure local agents + +

+ Permit connecting to a custom ACP agent over cleartext ws:// (e.g. a local agent binary on + 127.0.0.1). Off by default — secure wss:// is required otherwise. +

+
+ setLocalSetting('allowInsecureAcp', value)} /> +
+
) } diff --git a/src/stores/local-settings-store.ts b/src/stores/local-settings-store.ts index f7d6237c0..5e66bf390 100644 --- a/src/stores/local-settings-store.ts +++ b/src/stores/local-settings-store.ts @@ -12,6 +12,10 @@ type LocalSettingsState = { hapticsEnabled: boolean syncEnabled: boolean theme: 'light' | 'dark' | 'system' + // Opt-in: permit connecting to a custom ACP agent over insecure cleartext + // `ws://` (e.g. a local agent binary on 127.0.0.1). Off by default — the + // add-custom-agent dialog requires `wss://` unless this is enabled. + allowInsecureAcp: boolean } type LocalSettingsActions = { @@ -27,6 +31,7 @@ export const initialLocalSettings: LocalSettingsState = { hapticsEnabled: true, syncEnabled: false, theme: 'system', + allowInsecureAcp: false, } export const useLocalSettingsStore = create()( @@ -47,6 +52,7 @@ export const useLocalSettingsStore = create()( hapticsEnabled: s.hapticsEnabled, syncEnabled: s.syncEnabled, theme: s.theme, + allowInsecureAcp: s.allowInsecureAcp, }), }, ),