Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .changeset/30897-connect-agent-unreachable-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(app-shell): Connect an Agent marks claude.ai (web) and Doubao cloud tasks as not available on an address that is not public HTTPS

Clause-②: no
Metadata-protocol: none

claude.ai on the web and Doubao Work's cloud tasks connect from their vendor's servers, which reach only a public HTTPS address. When the address Connect an Agent shows is an `http://` address, `localhost`, a single-label or `.local` host, or a private, loopback or link-local IP, those two ways in now carry a "not available for this deployment" note with the reason in one sentence (naming the address) and what makes them available (serving the system at a public HTTPS address). Claude Desktop, Claude Code, Codex, Cursor and the other clients stay as they are, and on a public HTTPS address nothing changes. The five new strings ship in all ten console languages.
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
* The key form lives in the collapsed "Advanced: scripts and CI" section, so
* those cases open it first. The last block holds the page's shape: the
* endpoint and SKILL.md on top, a picker that shows one client's steps and
* remembers the choice on this browser, and the key form collapsed.
* remembers the choice on this browser, and the key form collapsed. The
* reachability block holds that claude.ai (web) and Doubao Work's cloud tasks
* are marked not available on an address that is not public HTTPS.
*
* `fetch` is stubbed by path; `t` echoes the KEY (with the `name` option
* appended when one is passed), as the sibling suites do, so the assertions
* read which string the widget asked for and with which name.
* `fetch` is stubbed by path; `t` echoes the KEY (with the `name` or
* `address` option appended when one is passed), as the sibling suites do,
* so the assertions read which string the widget asked for and with which
* value.
*/

import '@testing-library/jest-dom/vitest';
Expand All @@ -34,17 +37,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, opts?: { name?: string }) =>
opts && typeof opts.name === 'string' ? `«${key}|${opts.name}»` : `«${key}»`,
t: (key: string, opts?: { name?: string; address?: string }) => {
const arg = opts?.name ?? opts?.address;
return typeof arg === 'string' ? `«${key}|${arg}»` : `«${key}»`;
},
language: 'en',
}),
}));

import { ConnectAgent } from './ConnectAgentWidget';
import { isPublicHttpsAddress } from './deploymentReach';

const MINTED_KEY = 'osk_test_0123456789';

let fetchMock: ReturnType<typeof vi.fn>;
/** The `routes.mcp` discovery answers; a case may point it at another address. */
let discoveredMcp = '/api/v1/mcp';

/** The bodies of every `POST /api/v1/keys` the widget sent. */
function keyPosts(): unknown[] {
Expand All @@ -55,10 +63,11 @@ function keyPosts(): unknown[] {

beforeEach(() => {
window.localStorage.clear();
discoveredMcp = '/api/v1/mcp';
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/v1/discovery')) {
return new Response(JSON.stringify({ data: { routes: { mcp: '/api/v1/mcp' } } }), {
return new Response(JSON.stringify({ data: { routes: { mcp: discoveredMcp } } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
Expand Down Expand Up @@ -279,3 +288,73 @@ describe('Connect an Agent asks which client the person uses and shows only thos
expect(screen.getByText(CLAUDE_CODE)).toBeInTheDocument();
});
});

describe('Connect an Agent marks the cloud-connected ways in as not available on an address that is not public HTTPS', () => {
const notes = () => screen.queryAllByRole('note').map((n) => n.textContent ?? '');

it('§1 classifies the address: http, localhost, single-label and private hosts are not public HTTPS', () => {
for (const address of [
'http://crm.example.com/api/v1/mcp',
'https://localhost:3000/api/v1/mcp',
'https://app.localhost/api/v1/mcp',
'https://crm/api/v1/mcp',
'https://crm.local/api/v1/mcp',
'https://10.1.2.3/api/v1/mcp',
'https://172.20.0.5/api/v1/mcp',
'https://192.168.1.10/api/v1/mcp',
'https://127.0.0.1/api/v1/mcp',
'https://169.254.1.1/api/v1/mcp',
'https://100.64.0.1/api/v1/mcp',
'https://[::1]/api/v1/mcp',
'https://[fd12:3456::1]/api/v1/mcp',
'https://[fe80::1]/api/v1/mcp',
'https://[::ffff:10.0.0.1]/api/v1/mcp',
'not a url',
]) {
expect(isPublicHttpsAddress(address), address).toBe(false);
}
for (const address of [
'https://crm.example.com/api/v1/mcp',
'https://crm.example.com:8443/api/v1/mcp',
'https://8.8.8.8/api/v1/mcp',
'https://172.32.0.1/api/v1/mcp',
'https://[2001:db8::1]/api/v1/mcp',
]) {
expect(isPublicHttpsAddress(address), address).toBe(true);
}
});

it('§2 on an http://localhost address, claude.ai (web) and Doubao cloud tasks say why and what fixes it; local clients unchanged', async () => {
discoveredMcp = 'http://localhost:3000/api/v1/mcp';
await renderPage();
expect(notes()).toEqual([
'«connectAgent.unreachable.claudeWebTitle»«connectAgent.unreachable.claudeWebReason|http://localhost:3000»«connectAgent.unreachable.remedy»',
]);
expect(screen.queryByText('«connectAgent.claude.reachability»')).not.toBeInTheDocument();
// Claude Desktop and Claude Code keep their steps.
expect(screen.getByText('«connectAgent.claude.body»')).toBeInTheDocument();
expect(screen.getByText(/^claude mcp add --transport http objectstack http:\/\/localhost:3000\/api\/v1\/mcp$/)).toBeInTheDocument();

pickClient('«connectAgent.doubao.title»');
await screen.findByText('«connectAgent.doubao.body»');
expect(notes()).toEqual([
'«connectAgent.unreachable.doubaoCloudTitle»«connectAgent.unreachable.doubaoCloudReason|http://localhost:3000»«connectAgent.unreachable.remedy»',
]);

for (const local of ['Codex', 'Cursor']) {
pickClient(local);
await waitFor(() => expect(screen.getByRole('tab', { name: local })).toHaveAttribute('aria-selected', 'true'));
expect(notes()).toEqual([]);
}
});

it('§3 on a public HTTPS address nothing is marked', async () => {
discoveredMcp = 'https://crm.example.com/api/v1/mcp';
await renderPage();
expect(notes()).toEqual([]);
expect(screen.getByText('«connectAgent.claude.reachability»')).toBeInTheDocument();
pickClient('«connectAgent.doubao.title»');
await screen.findByText('«connectAgent.doubao.body»');
expect(notes()).toEqual([]);
});
});
55 changes: 53 additions & 2 deletions ui/packages/app-shell/src/console/connect/ConnectAgentWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
* OAuth is the default story for every client (the deployment is its own
* authorization server; you sign in as yourself); the Chinese clients'
* steps name the API-key header as the fallback for a client version
* without the OAuth flow,
* without the OAuth flow. claude.ai (web) and Doubao Work's cloud tasks
* connect from their vendor's servers, so on an address that is not
* public HTTPS (`deploymentReach.ts`) both are marked not available,
* with the reason and what makes them available; the local clients stay
* as they are,
* 4. API-key minting for headless callers (CI, machine accounts) via the
* existing `POST /api/v1/keys`, in a collapsed "Advanced: scripts and CI"
* section at the bottom — no client, so not a peer of the clients.
Expand Down Expand Up @@ -62,6 +66,7 @@ import {
TabsTrigger,
} from '@object-ui/components';
import {
Ban,
Bot,
Check,
ChevronRight,
Expand All @@ -82,6 +87,7 @@ import {
isConnectClientId,
type ConnectClientId,
} from './connectClientPreference.js';
import { isPublicHttpsAddress } from './deploymentReach.js';

const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');

Expand Down Expand Up @@ -154,6 +160,32 @@ function FieldRows({ rows }: { rows: Array<{ label: string; value: string }> })
);
}

/** The scheme and host a reader recognises (`http://localhost:3000`), not the full MCP path. */
function addressOf(url: string): string {
try {
return new URL(url).origin;
} catch {
return url;
}
}

/**
* A way in this deployment's address cannot offer: what is not available,
* why in one sentence, and what makes it available.
*/
function Unavailable({ title, reason, remedy }: { title: string; reason: string; remedy: string }) {
return (
<div role="note" className="flex gap-2 rounded-md border border-dashed px-3 py-2 text-xs">
<Ban className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="space-y-0.5">
<div className="font-medium">{title}</div>
<p className="text-muted-foreground">{reason}</p>
<p className="text-muted-foreground">{remedy}</p>
</div>
</div>
);
}

/** The `mcpServers` JSON shape most clients import, with the client's own transport spelling. */
function mcpServersJson(type: string | null, url: string): string {
const server = type ? { type, url } : { url };
Expand Down Expand Up @@ -198,6 +230,10 @@ export function ConnectAgent() {
}, []);

const skillUrl = mcpUrl ? `${mcpUrl}/skill` : null;
// claude.ai (web) and Doubao Work's cloud tasks connect from their vendor's
// servers: they reach only a public HTTPS address.
const cloudReachable = mcpUrl ? isPublicHttpsAddress(mcpUrl) : false;
const shownAddress = mcpUrl ? addressOf(mcpUrl) : '';

const cursorDeeplink = useMemo(() => {
if (!mcpUrl) return null;
Expand Down Expand Up @@ -324,7 +360,15 @@ export function ConnectAgent() {
<h4 className="font-medium">claude.ai / Claude Desktop</h4>
<p className="text-muted-foreground">{t('connectAgent.claude.body')}</p>
<Snippet text={mcpUrl} />
<p className="text-xs text-muted-foreground">{t('connectAgent.claude.reachability')}</p>
{cloudReachable ? (
<p className="text-xs text-muted-foreground">{t('connectAgent.claude.reachability')}</p>
) : (
<Unavailable
title={t('connectAgent.unreachable.claudeWebTitle')}
reason={t('connectAgent.unreachable.claudeWebReason', { address: shownAddress })}
remedy={t('connectAgent.unreachable.remedy')}
/>
)}
</div>
<div className="min-w-0 space-y-2">
<h4 className="font-medium">Claude Code</h4>
Expand Down Expand Up @@ -376,6 +420,13 @@ export function ConnectAgent() {
]}
/>
<p className="text-xs text-muted-foreground">{t('connectAgent.doubao.hint')}</p>
{!cloudReachable && (
<Unavailable
title={t('connectAgent.unreachable.doubaoCloudTitle')}
reason={t('connectAgent.unreachable.doubaoCloudReason', { address: shownAddress })}
remedy={t('connectAgent.unreachable.remedy')}
/>
)}
</TabsContent>

<TabsContent value="qwenwork" className="space-y-2 text-sm">
Expand Down
66 changes: 66 additions & 0 deletions ui/packages/app-shell/src/console/connect/deploymentReach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Whether a client that connects from its vendor's cloud (claude.ai on the
* web, Doubao Work's cloud tasks) can reach this deployment at the address
* Connect an Agent shows. Only a public HTTPS address is reachable from
* there: an `http://` address, `localhost`, a single-label or `.local` host
* name, and a loopback, private, link-local or carrier-grade NAT IP literal
* are not. A host name with a dot is taken as public: the page cannot see
* how the reader's DNS resolves it.
*/
export function isPublicHttpsAddress(address: string): boolean {
let url: URL;
try {
url = new URL(address);
} catch {
return false;
}
if (url.protocol !== 'https:') return false;
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
if (!host) return false;
if (host.includes(':')) return !isNonPublicIpv6(host);
const octets = ipv4Octets(host);
if (octets) return !isNonPublicIpv4(octets);
if (host === 'localhost' || host.endsWith('.localhost')) return false;
if (!host.includes('.')) return false;
if (host.endsWith('.local') || host.endsWith('.internal')) return false;
return true;
}

function ipv4Octets(host: string): number[] | null {
const parts = host.split('.');
if (parts.length !== 4 || !parts.every((p) => /^\d{1,3}$/.test(p))) return null;
const octets = parts.map(Number);
return octets.every((n) => n <= 255) ? octets : null;
}

function isNonPublicIpv4([a, b]: number[]): boolean {
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168)
);
}

function isNonPublicIpv6(host: string): boolean {
if (host === '::' || host === '::1') return true;
// IPv4-mapped (`::ffff:10.0.0.1`, or the hex form the URL parser writes).
const mapped = /^::ffff:(.+)$/.exec(host);
if (mapped) {
const dotted = ipv4Octets(mapped[1]);
if (dotted) return isNonPublicIpv4(dotted);
const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(mapped[1]);
if (hex) {
const hi = parseInt(hex[1], 16);
return isNonPublicIpv4([hi >> 8, hi & 0xff]);
}
}
const first = parseInt(host.split(':')[0] || '0', 16);
// fc00::/7 unique local, fe80::/10 link-local.
return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80;
}
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4113,6 +4113,13 @@ const ar = {
advanced: {
title: 'متقدم: البرامج النصية وCI',
},
unreachable: {
claudeWebTitle: 'claude.ai (الويب): غير متاح لهذا النشر',
claudeWebReason: 'يتصل claude.ai من خوادم Anthropic، وهي لا تستطيع الوصول إلى {{address}}: لا يمكن الوصول من هناك إلا إلى عنوان HTTPS عام.',
doubaoCloudTitle: 'مهام السحابة: غير متاحة لهذا النشر',
doubaoCloudReason: 'يشغّل Doubao Work مهام السحابة من خوادمه الخاصة، وهي لا تستطيع الوصول إلى {{address}}: لا يمكن الوصول من هناك إلا إلى عنوان HTTPS عام.',
remedy: 'قدّم هذا النظام على عنوان HTTPS عام (https:// على نطاق يمكن الوصول إليه من الإنترنت) ليصبح متاحًا.',
},
apiKey: {
title: 'مفاتيح API',
badge: 'بدون واجهة',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3808,6 +3808,13 @@ const de = {
advanced: {
title: 'Erweitert: Skripte und CI',
},
unreachable: {
claudeWebTitle: 'claude.ai (Web): für diese Bereitstellung nicht verfügbar',
claudeWebReason: 'claude.ai verbindet sich von den Servern von Anthropic, und diese erreichen {{address}} nicht: Von dort ist nur eine öffentliche HTTPS-Adresse erreichbar.',
doubaoCloudTitle: 'Cloud-Aufgaben: für diese Bereitstellung nicht verfügbar',
doubaoCloudReason: 'Doubao Work führt Cloud-Aufgaben auf den eigenen Servern aus, und diese erreichen {{address}} nicht: Von dort ist nur eine öffentliche HTTPS-Adresse erreichbar.',
remedy: 'Stellen Sie dieses System unter einer öffentlichen HTTPS-Adresse bereit (https:// auf einer aus dem Internet erreichbaren Domain), um es verfügbar zu machen.',
},
apiKey: {
title: 'API-Schlüssel',
badge: 'headless',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4615,6 +4615,13 @@ const en = {
advanced: {
title: 'Advanced: scripts and CI',
},
unreachable: {
claudeWebTitle: 'claude.ai (web): not available for this deployment',
claudeWebReason: 'claude.ai connects from Anthropic\'s servers, and they cannot reach {{address}}: only a public HTTPS address is reachable from there.',
doubaoCloudTitle: 'Cloud tasks: not available for this deployment',
doubaoCloudReason: 'Doubao Work runs cloud tasks from its own servers, and they cannot reach {{address}}: only a public HTTPS address is reachable from there.',
remedy: 'Serve this system at a public HTTPS address (https:// on a domain the internet can reach) to make it available.',
},
apiKey: {
title: 'API keys',
badge: 'headless',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3885,6 +3885,13 @@ const es = {
advanced: {
title: 'Avanzado: scripts y CI',
},
unreachable: {
claudeWebTitle: 'claude.ai (web): no disponible para esta implementación',
claudeWebReason: 'claude.ai se conecta desde los servidores de Anthropic, y estos no pueden alcanzar {{address}}: desde allí solo se alcanza una dirección HTTPS pública.',
doubaoCloudTitle: 'Tareas en la nube: no disponibles para esta implementación',
doubaoCloudReason: 'Doubao Work ejecuta las tareas en la nube desde sus propios servidores, y estos no pueden alcanzar {{address}}: desde allí solo se alcanza una dirección HTTPS pública.',
remedy: 'Sirve este sistema en una dirección HTTPS pública (https:// en un dominio accesible desde Internet) para que esté disponible.',
},
apiKey: {
title: 'Claves API',
badge: 'headless',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3881,6 +3881,13 @@ const fr = {
advanced: {
title: 'Avancé : scripts et CI',
},
unreachable: {
claudeWebTitle: 'claude.ai (web) : indisponible pour ce déploiement',
claudeWebReason: 'claude.ai se connecte depuis les serveurs d’Anthropic, qui ne peuvent pas atteindre {{address}} : seule une adresse HTTPS publique est joignable depuis là-bas.',
doubaoCloudTitle: 'Tâches cloud : indisponibles pour ce déploiement',
doubaoCloudReason: 'Doubao Work exécute les tâches cloud depuis ses propres serveurs, qui ne peuvent pas atteindre {{address}} : seule une adresse HTTPS publique est joignable depuis là-bas.',
remedy: 'Servez ce système à une adresse HTTPS publique (https:// sur un domaine joignable depuis Internet) pour le rendre disponible.',
},
apiKey: {
title: 'Clés API',
badge: 'headless',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3810,6 +3810,13 @@ const ja = {
advanced: {
title: '詳細設定:スクリプトと CI',
},
unreachable: {
claudeWebTitle: 'claude.ai(Web 版):このデプロイでは利用できません',
claudeWebReason: 'claude.ai は Anthropic のサーバーから接続するため、{{address}} には到達できません。到達できるのは公開 HTTPS アドレスだけです。',
doubaoCloudTitle: 'クラウドタスク:このデプロイでは利用できません',
doubaoCloudReason: 'Doubao Work のクラウドタスクは自社サーバーから実行されるため、{{address}} には到達できません。到達できるのは公開 HTTPS アドレスだけです。',
remedy: 'このシステムを公開 HTTPS アドレス(インターネットから到達できるドメインの https://)で提供すると利用できます。',
},
apiKey: {
title: 'API キー',
badge: 'ヘッドレス',
Expand Down
Loading
Loading