diff --git a/ROADMAP.md b/ROADMAP.md index dd8b05f9..796fb1fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,9 +131,9 @@ graph LR classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff; - classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff; + classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff; class connect_trust_preview,connect_trust_backup_visibility,connect_trust_undo done; - class connect_trust_tcc_copy todo; + class connect_trust_tcc_copy in_review; ``` | Task | Status | Refs | @@ -141,7 +141,7 @@ graph LR | US1: preview API + wizard diff UI (exact entry, API-key masking) | 🟒 Done | #802 | | US1: surface backup_path in Web UI + retention policy | 🟒 Done | #799 | | US2: one-click undo/disconnect in wizard | 🟒 Done | #804 | -| US2: pre-emptive macOS TCC explanation in wizard | βšͺ Todo | β€” | +| US2: pre-emptive macOS TCC explanation in wizard | 🟑 In review | #910 | diff --git a/frontend/src/components/OnboardingWizard.vue b/frontend/src/components/OnboardingWizard.vue index 86f77fef..24b51ea8 100644 --- a/frontend/src/components/OnboardingWizard.vue +++ b/frontend/src/components/OnboardingWizard.vue @@ -1349,6 +1349,32 @@ const ClientRow: FunctionalComponent< ]), ] ) + // Spec 078 US4 / FR-011: pre-emptive macOS App-Data forewarning. The preview + // read behind "Review & connect" is the first access that can fire the macOS + // privacy prompt, and only for configs under another app's protected data + // (~/Library/Application Support/…) β€” so the note is keyed off config_path + // (inherently macOS-only) and shown exactly where the action is offered, + // before it can fire. Once a preview is open the read already succeeded, so + // the note is dropped. The Spec 075 post-denial remediation is unchanged. + const offersConnect = c.supported && (c.exists || c.bridge) && !c.connected + const tccNote = + offersConnect && !props.preview && c.config_path.includes('/Library/Application Support/') + ? h( + 'p', + { + class: 'mt-1 text-[11px] opacity-60 leading-relaxed', + 'data-test': `client-tcc-note-${c.id}`, + }, + [ + 'macOS may ask for permission when you review or connect β€” the ', + h('span', { class: 'italic' }, 'β€œmcpproxy” wants to access data from other apps'), + ' prompt refers to this config file. Choose ', + h('strong', {}, 'Allow'), + ' so mcpproxy can read and update it.', + ] + ) + : null + // Spec 078 US2 / FR-006: after a connect performed in this wizard session, // surface the timestamped backup (or the honest "no prior file" case) right // in the row. undefined = no connect happened for this client yet. @@ -1566,6 +1592,7 @@ const ClientRow: FunctionalComponent< : null const children = [row] + if (tccNote) children.push(tccNote) if (backupLine) children.push(backupLine) if (undoPanel) children.push(undoPanel) if (previewPanel) children.push(previewPanel) diff --git a/frontend/tests/unit/onboarding-wizard-tcc-note.spec.ts b/frontend/tests/unit/onboarding-wizard-tcc-note.spec.ts new file mode 100644 index 00000000..ec93685b --- /dev/null +++ b/frontend/tests/unit/onboarding-wizard-tcc-note.spec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import OnboardingWizard from '@/components/OnboardingWizard.vue' +import api from '@/services/api' + +// Spec 078 US4 / FR-011: on macOS, the wizard must explain the App-Data privacy +// prompt BEFORE the first read that can trigger it. The per-client preview read +// behind "Review & connect" is that first read, and it only prompts for configs +// under another app's protected data (~/Library/Application Support/…), so the +// forewarning is keyed off the client's config_path β€” inherently macOS-only and +// scoped to exactly the clients whose connect can fire the prompt. + +vi.mock('@/services/api', () => ({ + default: { + getConnectStatus: vi.fn(), + getOnboardingState: vi.fn(), + markOnboardingState: vi.fn(), + getActivities: vi.fn(), + getConfig: vi.fn(), + getDockerStatus: vi.fn(), + getCanonicalConfigPaths: vi.fn(), + getConnectPreview: vi.fn(), + connectClient: vi.fn(), + }, +})) + +function onboardingState(connectedIds: string[]) { + return { + success: true, + data: { + has_connected_client: connectedIds.length > 0, + has_configured_server: true, + connected_client_count: connectedIds.length, + connected_client_ids: connectedIds, + configured_server_count: 1, + state: { engaged: false }, + should_show_wizard: true, + first_mcp_client_ever: false, + mcp_clients_seen_ever: [], + incomplete_tab_count: 0, + }, + } +} + +// Claude Desktop: config lives under another app's protected data on macOS β€” +// the exact shape whose preview read fires the App-Data prompt. +function protectedClient(overrides: Record = {}) { + return { + id: 'claude-desktop', + name: 'Claude Desktop', + config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json', + exists: true, + connected: false, + supported: true, + bridge: true, + icon: 'claude-desktop', + ...overrides, + } +} + +// Cursor: dot-directory config, no TCC protection β€” must never show the note +// (including on Linux/Windows daemons, where no path ever matches). +function unprotectedClient(overrides: Record = {}) { + return { + id: 'cursor', + name: 'Cursor', + config_path: '/Users/test/.cursor/mcp.json', + exists: true, + connected: false, + supported: true, + icon: 'cursor', + ...overrides, + } +} + +function previewOk(overrides: Record = {}) { + return { + success: true, + data: { + client: 'claude-desktop', + config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json', + format: 'json', + server_key: 'mcpServers', + server_name: 'mcpproxy', + entry: { type: 'sse', url: 'http://127.0.0.1:8080/mcp' }, + entry_text: '{\n "mcpproxy": {\n "url": "http://127.0.0.1:8080/mcp",\n "type": "sse"\n }\n}', + entry_exists: false, + contains_api_key: false, + access_state: 'accessible', + ...overrides, + }, + } +} + +async function openClientsTab(pinia: any) { + const wrapper = mount(OnboardingWizard, { + props: { show: false }, + global: { plugins: [pinia] }, + }) + await wrapper.setProps({ show: true }) + await flushPromises() + await wrapper.find('[data-test="tab-clients"]').trigger('click') + await flushPromises() + return wrapper +} + +describe('OnboardingWizard pre-emptive macOS TCC note (Spec 078 US4 / FR-011)', () => { + let pinia: any + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.clearAllMocks() + ;(api.getActivities as any).mockResolvedValue({ success: true, data: { activities: [] } }) + ;(api.getConfig as any).mockResolvedValue({ success: true, data: {} }) + ;(api.getDockerStatus as any).mockResolvedValue({ success: true, data: { available: false } }) + ;(api.getCanonicalConfigPaths as any).mockResolvedValue({ success: true, data: { paths: [] } }) + ;(api.getOnboardingState as any).mockResolvedValue(onboardingState([])) + ;(api.markOnboardingState as any).mockResolvedValue(onboardingState([])) + ;(api.getConnectStatus as any).mockResolvedValue({ + success: true, + data: [protectedClient(), unprotectedClient()], + }) + ;(api.getConnectPreview as any).mockResolvedValue(previewOk()) + }) + + it('forewarns about the macOS prompt on a protected-path client before any action', async () => { + const wrapper = await openClientsTab(pinia) + + const note = wrapper.find('[data-test="client-tcc-note-claude-desktop"]') + expect(note.exists()).toBe(true) + // Plain-language: names the OS prompt and tells the user what to choose. + expect(note.text()).toContain('macOS may ask for permission') + expect(note.text()).toContain('access data from other apps') + expect(note.text()).toContain('Allow') + }) + + it('never shows the note for clients whose config is not under protected app data', async () => { + const wrapper = await openClientsTab(pinia) + + expect(wrapper.find('[data-test="client-tcc-note-cursor"]').exists()).toBe(false) + }) + + it('does not show the note for an already-connected client', async () => { + ;(api.getConnectStatus as any).mockResolvedValue({ + success: true, + data: [protectedClient({ connected: true })], + }) + + const wrapper = await openClientsTab(pinia) + + expect(wrapper.find('[data-test="client-row-claude-desktop"]').exists()).toBe(true) + expect(wrapper.find('[data-test="client-tcc-note-claude-desktop"]').exists()).toBe(false) + }) + + it('drops the note once the preview is open (the protected read already succeeded)', async () => { + const wrapper = await openClientsTab(pinia) + + expect(wrapper.find('[data-test="client-tcc-note-claude-desktop"]').exists()).toBe(true) + + await wrapper.find('[data-test="connect-claude-desktop"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-test="client-preview-claude-desktop"]').exists()).toBe(true) + expect(wrapper.find('[data-test="client-tcc-note-claude-desktop"]').exists()).toBe(false) + }) + + it('does not show the note where no connect is offered (unsupported / not installed)', async () => { + ;(api.getConnectStatus as any).mockResolvedValue({ + success: true, + data: [ + protectedClient({ id: 'vscode', name: 'VS Code', bridge: false, exists: false }), + protectedClient({ id: 'other', name: 'Other', supported: false, reason: 'Not supported' }), + ], + }) + + const wrapper = await openClientsTab(pinia) + + expect(wrapper.find('[data-test="client-tcc-note-vscode"]').exists()).toBe(false) + expect(wrapper.find('[data-test="client-tcc-note-other"]').exists()).toBe(false) + }) +}) diff --git a/roadmap.yaml b/roadmap.yaml index 8f1bc3e2..5a50919c 100644 --- a/roadmap.yaml +++ b/roadmap.yaml @@ -368,7 +368,8 @@ epics: depends_on: [] - id: connect-trust-tcc-copy title: "US2: pre-emptive macOS TCC explanation in wizard" - status: todo + status: in_review + pr: "#910" depends_on: [] - id: telemetry-identity diff --git a/specs/078-connect-trust-preview/verification/tcc-note-report.html b/specs/078-connect-trust-preview/verification/tcc-note-report.html new file mode 100644 index 00000000..19c3b64e --- /dev/null +++ b/specs/078-connect-trust-preview/verification/tcc-note-report.html @@ -0,0 +1,9 @@ +Spec 078 US4 / FR-011 β€” pre-emptive macOS TCC note verification + +

Spec 078 US4 / FR-011 β€” pre-emptive macOS TCC forewarning

+
2 / 2 scenarios PASS β€” Playwright sweep (tcc-note.spec.ts) against a live mcpproxy v0.52.1+ build on 127.0.0.1:18081, real client registry (this Mac). Unit coverage: frontend/tests/unit/onboarding-wizard-tcc-note.spec.ts (5 tests). Protected-path clients that are not yet connected carry the forewarning; connected and dot-directory clients never do. No protected 'Review & connect' was clicked (that would fire a real TCC prompt on the host).
+
Wizard clients tab β€” pre-emptive TCC note β€” PASS +

Expected: VS Code (config under ~/Library/Application Support, not connected) shows the macOS forewarning under its row, right where 'Review & connect' is offered. Connected clients (Claude Desktop, Cursor, Codex β€” all protected-or-not) show no note.

Observed: Note visible on the VS Code row only; names the 'access data from other apps' prompt and says to choose Allow. PASS

+
VS Code row close-up β€” PASS +

Expected: The note text: 'macOS may ask for permission when you review or connect β€” the β€œmcpproxy” wants to access data from other apps prompt refers to this config file. Choose Allow so mcpproxy can read and update it.'

Observed: Copy present verbatim, plain language, before any action fires. PASS

+