Skip to content

Commit 1b5f6be

Browse files
feat: warn about coding plan app allowlists (#196)
## Summary Closes #195. This PR adds clearer user-facing warnings for coding-plan / relay / protocol-conversion endpoints that may reject Open CoDesign because of app allowlists, even when they advertise an OpenAI-compatible API. ### What changed - Added a compatibility notice next to the custom `Base URL` field in the Add Custom Provider modal. - Added an extra diagnostic hint for likely third-party gateways when connection failures look like app-allowlist / client-identity problems (`400` / `401` / `403` / parse errors on non-official hosts). - Added locale strings for `en`, `zh-CN`, and `pt-BR`. - Added tests for the new compatibility warning and the gateway allowlist heuristic. ## Why Users often connect Open CoDesign to coding plans, Claude Code protocol relays, or other OpenAI-compatible gateways that only allow specific clients such as Claude Code, openclaw, or Hermes. When that happens, the visible error can look like a normal provider compatibility failure (for example issue #184), even though the real cause is an app allowlist on the service side. This change does not try to work around those restrictions. It just makes the product more explicit about what users should check. ## Validation - `corepack pnpm --filter @open-codesign/desktop test -- AddCustomProviderModal ConnectionDiagnosticPanel` - `corepack pnpm --filter @open-codesign/desktop typecheck` - `git push` pre-push checks passed (`typecheck` + `biome check`) ## Notes - The repo still has an existing non-blocking Biome warning in `apps/desktop/src/renderer/src/components/Settings.tsx` about an extra hook dependency. This PR does not modify that file. --------- Signed-off-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-authored-by: Sun-sunshine06 <Sun-sunshine06@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent cff0328 commit 1b5f6be

7 files changed

Lines changed: 147 additions & 1 deletion

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { renderToStaticMarkup } from 'react-dom/server';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { AddCustomProviderModal } from './AddCustomProviderModal';
4+
5+
vi.mock('@open-codesign/i18n', () => ({
6+
useT: () => (key: string) => key,
7+
}));
8+
9+
describe('AddCustomProviderModal', () => {
10+
it('shows the compatibility warning for editable custom endpoints', () => {
11+
const html = renderToStaticMarkup(
12+
<AddCustomProviderModal onSave={() => undefined} onClose={() => undefined} />,
13+
);
14+
15+
expect(html).toContain('settings.providers.custom.compatibilityHintTitle');
16+
expect(html).toContain('settings.providers.custom.compatibilityHintBody');
17+
});
18+
19+
it('hides the compatibility warning when editing a locked builtin endpoint', () => {
20+
const html = renderToStaticMarkup(
21+
<AddCustomProviderModal
22+
onSave={() => undefined}
23+
onClose={() => undefined}
24+
editTarget={{
25+
id: 'anthropic',
26+
name: 'Anthropic',
27+
baseUrl: 'https://api.anthropic.com',
28+
wire: 'anthropic',
29+
defaultModel: 'claude-sonnet-4-5',
30+
builtin: true,
31+
lockEndpoint: true,
32+
}}
33+
/>,
34+
);
35+
36+
expect(html).not.toContain('settings.providers.custom.compatibilityHintTitle');
37+
expect(html).not.toContain('settings.providers.custom.compatibilityHintBody');
38+
});
39+
});

apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,17 @@ export function AddCustomProviderModal({
326326
placeholder="https://api.example.com/v1"
327327
disabled={lockEndpoint}
328328
/>
329+
{!lockEndpoint && (
330+
<div className="mt-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2 text-[var(--text-xs)] text-[var(--color-text-secondary)]">
331+
<div className="flex items-center gap-1.5 font-medium text-[var(--color-text-primary)]">
332+
<AlertCircle className="w-3.5 h-3.5 text-[var(--color-warning)]" />
333+
<span>{t('settings.providers.custom.compatibilityHintTitle')}</span>
334+
</div>
335+
<p className="mt-1 leading-5">
336+
{t('settings.providers.custom.compatibilityHintBody')}
337+
</p>
338+
</div>
339+
)}
329340
</Field>
330341

331342
<Field label={t('settings.providers.custom.apiKey')}>

apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ vi.mock('../store', () => ({
88
useCodesignStore: () => vi.fn(),
99
}));
1010

11-
import { isAbsoluteHttpUrl } from './ConnectionDiagnosticPanel';
11+
import { isAbsoluteHttpUrl, shouldShowGatewayAllowlistHint } from './ConnectionDiagnosticPanel';
1212

1313
describe('isAbsoluteHttpUrl', () => {
1414
it('rejects an empty string so /v1 quick-fix cannot produce a bare "/v1"', () => {
@@ -28,3 +28,33 @@ describe('isAbsoluteHttpUrl', () => {
2828
expect(isAbsoluteHttpUrl(' https://api.example.com ')).toBe(true);
2929
});
3030
});
31+
32+
describe('shouldShowGatewayAllowlistHint', () => {
33+
it('shows the hint for 400-class compatibility failures on third-party gateways', () => {
34+
expect(shouldShowGatewayAllowlistHint('400', 'https://relay.example.com/v1', undefined)).toBe(
35+
true,
36+
);
37+
expect(
38+
shouldShowGatewayAllowlistHint(
39+
'403',
40+
'https://relay.example.com/v1',
41+
'https://relay.example.com/v1/chat/completions',
42+
),
43+
).toBe(true);
44+
expect(shouldShowGatewayAllowlistHint('PARSE', 'https://relay.example.com/v1')).toBe(true);
45+
});
46+
47+
it('suppresses the hint for official providers and localhost proxies', () => {
48+
expect(shouldShowGatewayAllowlistHint('400', 'https://api.openai.com/v1')).toBe(false);
49+
expect(shouldShowGatewayAllowlistHint('403', 'https://api.anthropic.com')).toBe(false);
50+
expect(shouldShowGatewayAllowlistHint('400', 'http://127.0.0.1:8317')).toBe(false);
51+
});
52+
53+
it('suppresses the hint for unrelated error classes', () => {
54+
expect(shouldShowGatewayAllowlistHint('404', 'https://relay.example.com/v1')).toBe(false);
55+
expect(shouldShowGatewayAllowlistHint('429', 'https://relay.example.com/v1')).toBe(false);
56+
expect(shouldShowGatewayAllowlistHint('ECONNREFUSED', 'https://relay.example.com/v1')).toBe(
57+
false,
58+
);
59+
});
60+
});

apps/desktop/src/renderer/src/components/ConnectionDiagnosticPanel.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,49 @@ export function isAbsoluteHttpUrl(value: string): boolean {
1111
return /^https?:\/\/\S+/i.test(value.trim());
1212
}
1313

14+
const OFFICIAL_PROVIDER_HOST_SUFFIXES = [
15+
'openai.com',
16+
'anthropic.com',
17+
'openrouter.ai',
18+
'deepseek.com',
19+
'googleapis.com',
20+
'google.com',
21+
'x.ai',
22+
'mistral.ai',
23+
'groq.com',
24+
'cerebras.ai',
25+
'amazonaws.com',
26+
'azure.com',
27+
] as const;
28+
29+
function hostnameFromUrl(value: string | undefined): string | null {
30+
if (!value) return null;
31+
try {
32+
return new URL(value).hostname.toLowerCase();
33+
} catch {
34+
return null;
35+
}
36+
}
37+
38+
function isOfficialProviderHost(hostname: string | null): boolean {
39+
if (!hostname) return false;
40+
return OFFICIAL_PROVIDER_HOST_SUFFIXES.some(
41+
(suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`),
42+
);
43+
}
44+
45+
export function shouldShowGatewayAllowlistHint(
46+
errorCode: ErrorCode,
47+
baseUrl: string,
48+
attemptedUrl?: string,
49+
): boolean {
50+
const normalised = String(errorCode).toUpperCase();
51+
if (!['400', '401', '403', 'PARSE'].includes(normalised)) return false;
52+
const hostname = hostnameFromUrl(attemptedUrl) ?? hostnameFromUrl(baseUrl);
53+
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1') return false;
54+
return !isOfficialProviderHost(hostname);
55+
}
56+
1457
export interface ConnectionDiagnosticPanelProps {
1558
/** The error code returned by connection.test or generate */
1659
errorCode: ErrorCode;
@@ -58,6 +101,7 @@ export function ConnectionDiagnosticPanel({
58101
? fix.baseUrlTransform(baseUrl)
59102
: undefined;
60103
const canApplyFix = suggestedUrl !== undefined || fix?.externalUrl !== undefined;
104+
const showGatewayAllowlistHint = shouldShowGatewayAllowlistHint(errorCode, baseUrl, attemptedUrl);
61105

62106
function handleApplyFix() {
63107
if (suggestedUrl !== undefined) {
@@ -137,6 +181,16 @@ export function ConnectionDiagnosticPanel({
137181
{t('diagnostics.fix.addV1')}: {suggestedUrl}
138182
</p>
139183
)}
184+
{showGatewayAllowlistHint && (
185+
<div className="mt-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-3 py-2">
186+
<p className="font-medium text-[var(--color-text-primary)]">
187+
{t('diagnostics.gatewayAllowlistHintTitle')}
188+
</p>
189+
<p className="mt-1 text-[var(--text-xs)] leading-5">
190+
{t('diagnostics.gatewayAllowlistHintBody')}
191+
</p>
192+
</div>
193+
)}
140194
</div>
141195

142196
{/* Actions */}

packages/i18n/src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@
244244
"apiKey": "API Key",
245245
"apiKeyEditPlaceholder": "Leave empty to keep {{mask}}",
246246
"defaultModel": "Default model",
247+
"compatibilityHintTitle": "Compatibility note",
248+
"compatibilityHintBody": "Some coding plans, relay services, or OpenAI-compatible gateways only allow specific clients such as Claude Code, openclaw, or Hermes. Even if the API looks compatible, Open CoDesign may still be blocked by an app allowlist.",
247249
"switchToManual": "Enter manually",
248250
"switchToDropdown": "Pick from list",
249251
"discoveringModels": "Discovering models...",
@@ -852,6 +854,8 @@
852854
"testAgain": "Test again",
853855
"showLog": "Show full log",
854856
"showLogFailed": "Failed to open logs folder",
857+
"gatewayAllowlistHintTitle": "This endpoint may restrict which apps can connect",
858+
"gatewayAllowlistHintBody": "Some coding plans and protocol-conversion gateways keep an app allowlist. Even when they advertise an OpenAI-compatible API, they may still reject Open CoDesign unless this app is explicitly allowed.",
855859
"dismiss": "Dismiss",
856860
"report": {
857861
"title": "Report a bug",

packages/i18n/src/locales/pt-BR.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@
213213
"apiKey": "Chave de API",
214214
"apiKeyEditPlaceholder": "Deixe vazio para manter {{mask}}",
215215
"defaultModel": "Modelo padrão",
216+
"compatibilityHintTitle": "Aviso de compatibilidade",
217+
"compatibilityHintBody": "Alguns coding plans, relays e gateways compatíveis com OpenAI só permitem clientes específicos, como Claude Code, openclaw ou Hermes. Mesmo que a API pareça compatível, o Open CoDesign ainda pode ser bloqueado por uma lista de apps permitidos.",
216218
"test": "Testar conexão",
217219
"testOk": "OK — {{count}} modelos disponíveis",
218220
"save": "Salvar e continuar",
@@ -806,6 +808,8 @@
806808
"testAgain": "Testar novamente",
807809
"showLog": "Mostrar log completo",
808810
"showLogFailed": "Falha ao abrir a pasta de logs",
811+
"gatewayAllowlistHintTitle": "Este endpoint pode restringir quais apps podem se conectar",
812+
"gatewayAllowlistHintBody": "Alguns coding plans e gateways de conversão de protocolo mantêm uma lista de apps permitidos. Mesmo anunciando uma API compatível com OpenAI, eles ainda podem recusar o Open CoDesign se este app não estiver explicitamente liberado.",
809813
"dismiss": "Dispensar",
810814
"report": {
811815
"title": "Reportar um bug",

packages/i18n/src/locales/zh-CN.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@
244244
"apiKey": "API Key",
245245
"apiKeyEditPlaceholder": "留空则保留 {{mask}}",
246246
"defaultModel": "默认模型",
247+
"compatibilityHintTitle": "\u517c\u5bb9\u6027\u63d0\u793a",
248+
"compatibilityHintBody": "\u90e8\u5206 coding plan\uff0c\u534f\u8bae\u8f6c\u6362\u670d\u52a1\u6216 OpenAI \u517c\u5bb9\u7f51\u5173\u53ea\u5141\u8bb8\u7279\u5b9a\u5ba2\u6237\u7aef\uff08\u5982 Claude Code\uff0copenclaw\uff0cHermes\uff09\u8bbf\u95ee\u3002\u5373\u4f7f API \u770b\u8d77\u6765\u517c\u5bb9\uff0cOpen CoDesign \u4e5f\u53ef\u80fd\u56e0\u4e3a\u5e94\u7528\u767d\u540d\u5355\u800c\u65e0\u6cd5\u4f7f\u7528\u3002",
247249
"switchToManual": "手动输入",
248250
"switchToDropdown": "从列表选择",
249251
"discoveringModels": "正在发现模型…",
@@ -848,6 +850,8 @@
848850
"testAgain": "再次测试",
849851
"showLog": "查看完整日志",
850852
"showLogFailed": "无法打开日志文件夹",
853+
"gatewayAllowlistHintTitle": "\u8fd9\u4e2a\u7aef\u70b9\u53ef\u80fd\u9650\u5236\u53ef\u63a5\u5165\u7684\u5e94\u7528",
854+
"gatewayAllowlistHintBody": "\u90e8\u5206 coding plan \u548c\u534f\u8bae\u8f6c\u6362\u7f51\u5173\u4f1a\u914d\u7f6e\u5e94\u7528\u767d\u540d\u5355\u3002\u5b83\u4eec\u5373\u4f7f\u58f0\u79f0\u63d0\u4f9b OpenAI \u517c\u5bb9 API\uff0c\u4e5f\u53ef\u80fd\u5728\u6ca1\u6709\u663e\u5f0f\u5141\u8bb8 Open CoDesign \u7684\u60c5\u51b5\u4e0b\u62d2\u7edd\u8bf7\u6c42\u3002",
851855
"dismiss": "关闭",
852856
"report": {
853857
"title": "上报问题",

0 commit comments

Comments
 (0)