Skip to content
Open
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
25 changes: 16 additions & 9 deletions src/components/settings/agents/add-custom-agent-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
})

Expand Down
25 changes: 20 additions & 5 deletions src/components/settings/agents/add-custom-agent-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 }
}

Expand Down Expand Up @@ -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 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add stays enabled after opt-in off

Low Severity

canSubmit still keys off a prior successful connection test, but URL validity now also depends on allowInsecureAcp. If the user disables “Allow insecure local agents” while the dialog still has a tested ws:// URL, the inline error appears yet Add Agent can remain enabled; handleSubmit then no-ops without feedback.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26029c1. Configure here.

// 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
Expand Down
23 changes: 22 additions & 1 deletion src/settings/dev-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <K extends keyof typeof settings>(key: K) => settings[key] !== initialLocalSettings[key]
Expand Down Expand Up @@ -113,6 +114,26 @@ export default function DevSettingsPage() {
</div>
</div>
</SectionCard>

<SectionCard title="Agents">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<ModificationIndicator
as="label"
className="text-sm font-medium"
hasModifications={isModified('allowInsecureAcp')}
onReset={() => resetSetting('allowInsecureAcp')}
>
Allow insecure local agents
</ModificationIndicator>
<p className="text-sm text-muted-foreground">
Permit connecting to a custom ACP agent over cleartext <code>ws://</code> (e.g. a local agent binary on
127.0.0.1). Off by default — secure <code>wss://</code> is required otherwise.
</p>
</div>
<Switch checked={allowInsecureAcp} onCheckedChange={(value) => setLocalSetting('allowInsecureAcp', value)} />
</div>
</SectionCard>
</div>
)
}
6 changes: 6 additions & 0 deletions src/stores/local-settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -27,6 +31,7 @@ export const initialLocalSettings: LocalSettingsState = {
hapticsEnabled: true,
syncEnabled: false,
theme: 'system',
allowInsecureAcp: false,
}

export const useLocalSettingsStore = create<LocalSettingsStore>()(
Expand All @@ -47,6 +52,7 @@ export const useLocalSettingsStore = create<LocalSettingsStore>()(
hapticsEnabled: s.hapticsEnabled,
syncEnabled: s.syncEnabled,
theme: s.theme,
allowInsecureAcp: s.allowInsecureAcp,
}),
},
),
Expand Down
Loading