From 076a22767e38d7831dccaafa7977411163bb276a Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 14:19:11 +0200 Subject: [PATCH] Credential fields opt out of browser autofill; body-key adapters are not "Public API" Found while testing the install form in a real browser: Chrome filled the Odoo 14-18 form's "ODOO UID" with the signed-in user's e-mail and "ODOO API KEY" with their saved AnythingMCP password. A text field followed by a password field reads as a login form. The same happens on a connector's Basic Auth / login-token / OAuth editor, and it matches two live cases: a BuchhaltungsButler connector whose Basic Auth held the user's login e-mail and password, and an Odoo connector whose database held the user's e-mail. - Install form: every variable gets a non-login name, autocomplete "off" (or "new-password" for secrets) and the opt-out attributes of 1Password, LastPass, Bitwarden and Dashlane. - Connector page: the same on the auth editor's username / client id and password / secret / token fields that had none. - Marketplace chip: an adapter with authType NONE that still requires a key/token/secret/password variable (Odoo's JSON-RPC, Telegram, Bluesky, ...) is labelled "API Key", not "Public API". --- .../frontend/src/app/connectors/[id]/page.tsx | 18 +++--- .../src/app/connectors/store/page.tsx | 18 ++++-- packages/frontend/src/lib/utils.ts | 25 ++++++++ .../e2e/install-form-no-autofill.spec.ts | 57 +++++++++++++++++++ 4 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 packages/frontend/tests/e2e/install-form-no-autofill.spec.ts diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index 8f133bb6..e294e59d 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -1147,29 +1147,29 @@ export default function ConnectorDetailPage() {
- setEditAuthKey(e.target.value)} placeholder="X-API-Key" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthKey(e.target.value)} placeholder="X-API-Key" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
- setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
)} {editAuthType === 'BEARER_TOKEN' && (
- setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
)} {editAuthType === 'BASIC_AUTH' && (
- setEditAuthKey(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthKey(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
- setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
)} @@ -1178,11 +1178,11 @@ export default function ConnectorDetailPage() {
- setEditAuthKey(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthKey(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
- setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
@@ -1266,11 +1266,11 @@ export default function ConnectorDetailPage() {
- setEditLtUsername(e.target.value)} placeholder="access key" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm font-mono bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditLtUsername(e.target.value)} placeholder="access key" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm font-mono bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
- setEditLtPassword(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm font-mono bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + setEditLtPassword(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm font-mono bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
diff --git a/packages/frontend/src/app/connectors/store/page.tsx b/packages/frontend/src/app/connectors/store/page.tsx index 8bae4e7d..c641df8a 100644 --- a/packages/frontend/src/app/connectors/store/page.tsx +++ b/packages/frontend/src/app/connectors/store/page.tsx @@ -11,7 +11,7 @@ import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Button, buttonVariants } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { authTypeLabel, cn } from '@/lib/utils'; +import { adapterAuthLabel, adapterNeedsCredentials, cn } from '@/lib/utils'; import { McpAssignModal } from '@/components/mcp-assign-modal'; import { matchesSearch } from '@/lib/marketplace-search'; import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit'; @@ -606,7 +606,7 @@ function AdapterStoreContent() { ) : (
{filtered.map((adapter) => { - const isPublic = adapter.authType === 'NONE'; + const isPublic = !adapterNeedsCredentials(adapter); const isImporting = importing === adapter.slug; /* log-ish 1..10 segment scale, same as the marketing-site card */ const fillCount = Math.max( @@ -666,7 +666,7 @@ function AdapterStoreContent() { className="max-w-full min-w-0 gap-1 truncate font-mono uppercase tracking-wider" > {isPublic ? : } - {authTypeLabel(adapter.authType)} + {adapterAuthLabel(adapter)} )} {adapter.docsUrl && ( @@ -734,7 +734,7 @@ function AdapterStoreContent() {
- Auth type: {authTypeLabel(configAdapter.connector?.authType)} + Auth type: {adapterAuthLabel({ authType: configAdapter.connector?.authType, requiredEnvVars: configAdapter.requiredEnvVars })}
{/* Setup instructions — collapsible details block, default open @@ -784,6 +784,16 @@ function AdapterStoreContent() {
diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index 9c2bb526..c6aee376 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -31,3 +31,28 @@ export function authTypeLabel(authType: string | null | undefined): string { if (!authType) return 'None'; return AUTH_TYPE_LABELS[authType] ?? authType; } + +/** A variable that holds a credential, judged by its name. */ +const SECRET_VARIABLE = /KEY|TOKEN|SECRET|PASSWORD/i; + +/** + * Whether an adapter needs a credential from the user. Some carry the key in + * the request body (Odoo's JSON-RPC) or a URL, so their authType is NONE + * although an API key is required: the variables decide too. + */ +export function adapterNeedsCredentials(adapter: { + authType?: string | null; + requiredEnvVars?: string[] | null; +}): boolean { + if (adapter.authType && adapter.authType !== 'NONE') return true; + return (adapter.requiredEnvVars ?? []).some((v) => SECRET_VARIABLE.test(v)); +} + +/** The auth chip of a catalog adapter: "API Key" rather than "Public API" when a key is needed. */ +export function adapterAuthLabel(adapter: { + authType?: string | null; + requiredEnvVars?: string[] | null; +}): string { + if (adapter.authType === 'NONE' && adapterNeedsCredentials(adapter)) return 'API Key'; + return authTypeLabel(adapter.authType); +} diff --git a/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts b/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts new file mode 100644 index 00000000..9f85649b --- /dev/null +++ b/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; + +/** + * The install form asks for API credentials. A browser that sees a text field + * followed by a password field takes it for a login and fills in the saved + * AnythingMCP e-mail and password: seen on live installs, where a user's own + * login ended up as a connector's Basic Auth or Odoo user id. The fields must + * opt out of autofill and password managers. + * + * And an adapter whose key travels in the body (authType NONE, Odoo's + * JSON-RPC) is not a "Public API". + */ + +const USER = { id: 'u1', email: 'test@example.com', name: 'Test User', role: 'ADMIN', organizationId: 'o1', emailVerified: true }; +const ODOO = { + slug: 'odoo-jsonrpc', name: 'Odoo 14-18 (JSON-RPC)', description: 'Odoo over JSON-RPC.', region: 'intl', category: 'erp', + icon: 'odoo', docsUrl: null, requiredEnvVars: ['ODOO_URL', 'ODOO_DB', 'ODOO_UID', 'ODOO_API_KEY'], toolCount: 11, authType: 'NONE', +}; +const HN = { slug: 'hackernews', name: 'Hacker News', description: 'Public stories.', region: 'intl', category: 'news', icon: 'hackernews', docsUrl: null, requiredEnvVars: [], toolCount: 5, authType: 'NONE' }; + +test('credential fields opt out of autofill, and a body-key adapter is not labelled public', async ({ page, baseURL }) => { + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: baseURL! }]); + await page.addInitScript((u) => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(u)); + }, USER); + await page.route(/\/api\//, async (route) => { + const url = route.request().url(); + const json = (b: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) }); + if (/\/api\/adapters\/odoo-jsonrpc$/.test(url)) { + return json({ ...ODOO, instructions: 'Four values.', connector: { authType: 'NONE', baseUrl: '{{ODOO_URL}}' }, tools: [] }); + } + if (/\/api\/adapters(\?|$)/.test(url)) return json([ODOO, HN]); + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (url.includes('/api/connectors')) return json([]); + return json({}); + }); + + await page.goto('/connectors/store'); + await expect(page.getByText('Odoo 14-18 (JSON-RPC)', { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('API Key', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Public API', { exact: true })).toHaveCount(1); // Hacker News only + + await page.getByRole('button', { name: 'Install' }).first().click(); + const uid = page.locator('#cred-ODOO_UID'); + const key = page.locator('#cred-ODOO_API_KEY'); + await expect(uid).toBeVisible({ timeout: 10_000 }); + await expect(uid).toHaveAttribute('autocomplete', 'off'); + await expect(key).toHaveAttribute('autocomplete', 'new-password'); + for (const field of [uid, key]) { + await expect(field).toHaveAttribute('data-1p-ignore'); + await expect(field).toHaveAttribute('data-lpignore', 'true'); + await expect(field).toHaveAttribute('name', /^amcp-connector-var-/); + } +});