From 00014d3ca3bcecae4ac0b1db166fd17332917c3d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:43:12 -0700 Subject: [PATCH 1/2] feat(byo): record setup OS, listener eligibility and per-type copy marks (TASK-714) The BYO result page now sends the OS it set up for and whether the listener could run there, with each step. The server writes both once per person. Each copy also carries a fixed copy type (listen, mcp-claude, mcp-cursor, daemon, token), and each type gets its own write-once mark, so a later listen copy after an MCP copy leaves a trace. The existing first-copy marks are unchanged. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../unit/routes/stats.byo-step.test.js | 27 ++++++++ .../unit/services/onboardingMarks.test.js | 50 ++++++++++++++ backend/models/User.ts | 16 +++++ backend/routes/stats.ts | 11 ++- backend/services/onboardingMarks.ts | 69 +++++++++++++++++++ .../V2AgentBYOCopyTelemetry.test.tsx | 54 +++++++++++++++ .../__tests__/V2AgentBYOHostedOffer.test.tsx | 4 +- frontend/src/v2/components/V2AgentBYO.tsx | 22 +++--- frontend/src/v2/utils/byoTelemetry.ts | 16 ++++- 9 files changed, 255 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/routes/stats.byo-step.test.js b/backend/__tests__/unit/routes/stats.byo-step.test.js index 153ab258f9..cdcba9076e 100644 --- a/backend/__tests__/unit/routes/stats.byo-step.test.js +++ b/backend/__tests__/unit/routes/stats.byo-step.test.js @@ -2,6 +2,8 @@ const request = require('supertest'); const express = require('express'); const mockMarkByoCommandCopied = jest.fn(); +const mockMarkByoCopyType = jest.fn(); +const mockMarkByoSetup = jest.fn(); const mockMarkSurface = jest.fn(); const mockInsertOne = jest.fn().mockResolvedValue({ acknowledged: true }); const mockPassThrough = () => (_req, _res, next) => next(); @@ -19,6 +21,8 @@ jest.mock('../../../middleware/auth', () => (req, _res, next) => { }); jest.mock('../../../services/onboardingMarks', () => ({ markByoCommandCopied: (...args) => mockMarkByoCommandCopied(...args), + markByoCopyType: (...args) => mockMarkByoCopyType(...args), + markByoSetup: (...args) => mockMarkByoSetup(...args), markSurface: (...args) => mockMarkSurface(...args), })); jest.mock('express-rate-limit', () => ({ @@ -94,6 +98,29 @@ describe('POST /api/stats/byo-step', () => { expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) }); }); + it('a copy step passes the copy type and the setup the page showed to the marks', async () => { + const res = await request(app).post('/api/stats/byo-step') + .send({ step: 'mcp-command-copied', copyType: 'mcp-claude', setupOs: 'mac', listenerAvailable: true }); + + expect(res.status).toBe(200); + expect(mockMarkByoCopyType).toHaveBeenCalledWith('human-1', 'mcp-claude'); + expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'mac', listenerAvailable: true }); + // The event row is unchanged: the copy type and setup stay out of telemetry. + expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({ + step: 'mcp-command-copied', + commandType: 'mcp', + })); + expect(JSON.stringify(mockInsertOne.mock.calls[0][0])).not.toContain('mcp-claude'); + }); + + it('a non-copy step records the setup but never a copy type', async () => { + await request(app).post('/api/stats/byo-step') + .send({ step: 'seat-created', copyType: 'token', setupOs: 'windows', listenerAvailable: false }); + + expect(mockMarkByoCopyType).not.toHaveBeenCalled(); + expect(mockMarkByoSetup).toHaveBeenCalledWith('human-1', { setupOs: 'windows', listenerAvailable: false }); + }); + it('other steps set no surface mark', async () => { await request(app).post('/api/stats/byo-step').send({ step: 'page-viewed' }); expect(mockMarkSurface).not.toHaveBeenCalled(); diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 21e63bc251..40eb7e960f 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -11,6 +11,7 @@ jest.mock('../../../models/User', () => ({ const { markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied, markByoConnectionAttempt, markWelcomeHostedStart, markSurface, resetOnboardingMarksForTest, + markByoCopyType, markByoSetup, } = require('../../../services/onboardingMarks'); const flush = () => new Promise((r) => setImmediate(r)); @@ -259,6 +260,55 @@ describe('BYO setup marks', () => { }); }); +describe('markByoCopyType and markByoSetup (TASK-714)', () => { + test('each command type gets its own write-once mark, so a later listen copy after MCP leaves a trace', () => { + markByoCopyType('u1', 'mcp-claude'); + markByoCopyType('u1', 'listen'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoMcpClaudeCopiedAt': { $exists: false } }, + { $set: { 'onboarding.byoMcpClaudeCopiedAt': expect.any(Date) } }, + ); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenCopiedAt': { $exists: false } }, + { $set: { 'onboarding.byoListenCopiedAt': expect.any(Date) } }, + ); + }); + + test('each type is written once per person, however often it is copied', () => { + markByoCopyType('u1', 'daemon'); + markByoCopyType('u1', 'daemon'); + markByoCopyType('u1', 'daemon'); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + }); + + test('refuses an unknown copy type, so the page cannot write an arbitrary field', () => { + markByoCopyType('u1', 'constructor'); + markByoCopyType('u1', 'listen; drop'); + markByoCopyType('u1', undefined); + markByoCopyType('', 'token'); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + test('writes the setup OS and listener eligibility once, and nothing else', () => { + markByoSetup('u1', { setupOs: 'mac', listenerAvailable: true }); + markByoSetup('u1', { setupOs: 'windows', listenerAvailable: false }); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } }, + { $set: { 'onboarding.byoSetupOs': 'mac', 'onboarding.byoListenerEligible': true } }, + ); + expect(JSON.stringify(mockUpdateOne.mock.calls)).not.toMatch(/token|command|pod/i); + }); + + test('a missing or malformed setup writes nothing', () => { + markByoSetup('u1', { setupOs: 'mac' }); + markByoSetup('u1', { setupOs: 'plan9', listenerAvailable: true }); + markByoSetup('u1', { setupOs: 'linux', listenerAvailable: 'yes' }); + markByoSetup('u1', undefined); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); +}); + describe('markSurface', () => { test.each(['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'])('%s is write-once in the filter, never for bots', (mark) => { markSurface('u1', mark); diff --git a/backend/models/User.ts b/backend/models/User.ts index 80c8d60814..254aa23bd4 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -222,6 +222,15 @@ export interface IUser extends Document { /** First copy of each setup command, kept per kind (TASK-721). */ byoMcpCopiedAt?: Date; byoCliCopiedAt?: Date; + /** First copy of each command type, each its own write-once mark (TASK-714). */ + byoListenCopiedAt?: Date; + byoMcpClaudeCopiedAt?: Date; + byoMcpCursorCopiedAt?: Date; + byoDaemonCopiedAt?: Date; + byoTokenCopiedAt?: Date; + /** The OS the result page set up for, and whether the listener could run there. */ + byoSetupOs?: 'mac' | 'linux' | 'windows' | 'other'; + byoListenerEligible?: boolean; /** First authenticated BYO runtime request, never overwritten. */ byoFirstConnectionAttemptAt?: Date; /** First "invite a teammate" card mounted, and first dismissed. */ @@ -479,6 +488,13 @@ const userSchema = new Schema({ byoCommandType: { type: String, enum: ['mcp', 'cli'] }, byoMcpCopiedAt: { type: Date }, byoCliCopiedAt: { type: Date }, + byoListenCopiedAt: { type: Date }, + byoMcpClaudeCopiedAt: { type: Date }, + byoMcpCursorCopiedAt: { type: Date }, + byoDaemonCopiedAt: { type: Date }, + byoTokenCopiedAt: { type: Date }, + byoSetupOs: { type: String, enum: ['mac', 'linux', 'windows', 'other'] }, + byoListenerEligible: { type: Boolean }, byoFirstConnectionAttemptAt: { type: Date }, inviteCardShownAt: { type: Date }, inviteCardDismissedAt: { type: Date }, diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts index 192b188955..973ff9e8aa 100644 --- a/backend/routes/stats.ts +++ b/backend/routes/stats.ts @@ -78,7 +78,7 @@ router.get('/public', statsPublicLimit, async (_req: unknown, res: { json: (d: u // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require -const { markByoCommandCopied, markSurface } = require('../services/onboardingMarks'); +const { markByoCommandCopied, markByoCopyType, markByoSetup, markSurface } = require('../services/onboardingMarks'); // eslint-disable-next-line global-require const { rateLimit } = require('express-rate-limit'); // eslint-disable-next-line global-require @@ -91,6 +91,8 @@ const BYO_STEPS = new Set([ 'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked', 'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown', ]); +// Steps that copy a setup command; each carries the copy type the page sent. +const COPY_STEPS = new Set(['mcp-command-copied', 'cli-command-copied', 'daemon-command-copied', 'token-copied']); // Onboarding surfaces: each step also sets its write-once onboarding mark. const SURFACE_STEPS: Record = { 'invite-card-shown': 'inviteCardShownAt', @@ -114,6 +116,13 @@ router.post('/byo-step', rateLimit({ // the copied command, which contains a runtime token. markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli'); } + if (COPY_STEPS.has(step)) { + // The copy type is a fixed name the page sends; the service refuses anything else. + markByoCopyType(req.userId, req.body?.copyType); + } + // The setup the result page showed. The service validates both fields and + // writes nothing for a missing or malformed value. + markByoSetup(req.userId, { setupOs: req.body?.setupOs, listenerAvailable: req.body?.listenerAvailable }); if (SURFACE_STEPS[step]) markSurface(req.userId, SURFACE_STEPS[step]); await recordByoEvent(req.userId, step as any, { ...(step === 'mcp-command-copied' ? { commandType: 'mcp' } : {}), diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 71265733d1..584f52706e 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -192,6 +192,73 @@ export function markByoCommandCopied(userId: unknown, commandType: 'mcp' | 'cli' } } +/** + * The first copy of each setup command, by its own type (TASK-714). + * + * `listen` is the listener command; `mcp-claude` and `mcp-cursor` are the two + * MCP snippets; `daemon` and `token` are the daemon and token copies. Each + * gets its own write-once field, so a later listen copy after an MCP copy + * leaves a trace. The existing first-copy marks above are unchanged. + */ +export const BYO_COPY_TYPES = ['listen', 'mcp-claude', 'mcp-cursor', 'daemon', 'token'] as const; +export type ByoCopyType = typeof BYO_COPY_TYPES[number]; +const BYO_COPY_FIELD: Record = { + listen: 'onboarding.byoListenCopiedAt', + 'mcp-claude': 'onboarding.byoMcpClaudeCopiedAt', + 'mcp-cursor': 'onboarding.byoMcpCursorCopiedAt', + daemon: 'onboarding.byoDaemonCopiedAt', + token: 'onboarding.byoTokenCopiedAt', +}; +const byoCopyTypeMarked = new Set(); + +export function markByoCopyType(userId: unknown, copyType: unknown): void { + const id = String(userId || ''); + if (!id || !(BYO_COPY_TYPES as readonly unknown[]).includes(copyType)) return; + const type = copyType as ByoCopyType; + const key = `${id}:${type}`; + if (byoCopyTypeMarked.has(key)) return; + byoCopyTypeMarked.add(key); + const field = BYO_COPY_FIELD[type]; + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, [field]: { $exists: false } }, + { $set: { [field]: new Date() } }, + ).catch(() => { + byoCopyTypeMarked.delete(key); + }); + } catch { + // Telemetry fails open: it must never break the copy flow. + byoCopyTypeMarked.delete(key); + } +} + +/** + * The OS the result page set up for and whether its listener could run there, + * written once per person (TASK-714). Booleans and the fixed OS enum only: no + * command, token, or pod is stored. A missing or malformed value writes nothing. + */ +const byoSetupMarked = new Set(); + +type ByoSetupInput = { setupOs?: unknown; listenerAvailable?: unknown } | null | undefined; +export function markByoSetup(userId: unknown, setup: ByoSetupInput): void { + const id = String(userId || ''); + const setupOs = parseByoOpenedOs(setup?.setupOs); + const listenerEligible = setup?.listenerAvailable; + if (!id || !setupOs || typeof listenerEligible !== 'boolean' || byoSetupMarked.has(id)) return; + byoSetupMarked.add(id); + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, 'onboarding.byoListenerEligible': { $exists: false } }, + { $set: { 'onboarding.byoSetupOs': setupOs, 'onboarding.byoListenerEligible': listenerEligible } }, + ).catch(() => { + byoSetupMarked.delete(id); + }); + } catch { + // Telemetry fails open: it must never break the copy flow. + byoSetupMarked.delete(id); + } +} + /** * Record the first authenticated request from a BYO/local runtime. * @@ -262,6 +329,8 @@ export function resetOnboardingMarksForTest(): void { byoOpenedMarked.clear(); byoCommandCopiedMarked.clear(); byoCommandKindMarked.clear(); + byoCopyTypeMarked.clear(); + byoSetupMarked.clear(); byoConnectionAttemptMarked.clear(); surfaceMarked.clear(); } diff --git a/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx index 10315d65f0..0b03538498 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOCopyTelemetry.test.tsx @@ -99,3 +99,57 @@ test('a failed daemon copy posts no copy step', async () => { expect(stepsPosted()).not.toContain('daemon-command-copied'); }); + +// TASK-714: the copy says which command it was and what the page showed, so +// the server can tell "the listener was possible" from "it was not". +const withUserAgent = (ua: string) => { + Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: ua }); +}; +const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'; +const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'; + +// Issue a seat so the result page renders (the same path the listen-snippet +// suite drives), then return the page's own setup telemetry. +const issueSeatAndReadSetup = async () => { + axios.post + .mockResolvedValueOnce({ data: { ok: true } }) // /api/registry/install + .mockResolvedValueOnce({ data: { token: 'cm_agent_test_token' } }); // runtime-tokens + renderPage(); + await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); + fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); + await screen.findByText(/your connect command for/i); + return recordByoStep.mock.calls.find(([, step]) => step === 'seat-created'); +}; + +test.each([ + ['mac', MAC_UA, true], + ['windows', WINDOWS_UA, false], +])('the %s result page records the setup it shows (listener %s)', async (os, ua, listenerAvailable) => { + const original = window.navigator.userAgent; + withUserAgent(ua); + try { + const call = await issueSeatAndReadSetup(); + expect(call?.[2]).toEqual({ setupOs: os, listenerAvailable }); + } finally { + withUserAgent(original); + } +}); + +test('a Mac daemon copy posts its command type with the setup the page showed', async () => { + const original = window.navigator.userAgent; + withUserAgent(MAC_UA); + try { + renderPage(); + const block = await screen.findByTestId('byo-add-computer'); + await act(async () => { + fireEvent.click(block.querySelector('button.v2-byo__copy')); + }); + + const call = recordByoStep.mock.calls.find(([, step]) => step === 'daemon-command-copied'); + expect(call?.[2]).toEqual({ setupOs: 'mac', listenerAvailable: true, copyType: 'daemon' }); + expect(JSON.stringify(call)).not.toMatch(/commonly daemon|cm_agent/); + } finally { + withUserAgent(original); + } +}); diff --git a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx index ca87d134bc..ca372334eb 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx @@ -91,7 +91,7 @@ test('exit intent offers immediately; manual copy dismisses permanently', async test('button copy suppresses the offer beyond the Copied label', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } }); await issue(); await act(async () => fireEvent.click(screen.getByTestId('byo-listen').querySelector('button.v2-byo__copy'))); await tick(60000); expect(offer()).not.toBeInTheDocument(); - expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', { step: 'cli-command-copied' }, expect.anything()); + expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', expect.objectContaining({ step: 'cli-command-copied', copyType: 'listen' }), expect.anything()); }); test('hosting errors preserve the command and a retryable offer', async () => { await issue(); axios.post.mockImplementation((url) => url === '/api/registry/install' ? Promise.reject({ response: { data: { code: 'hosted_cap_reached', cap: 1 } } }) : Promise.resolve({ data: {} })); @@ -102,7 +102,7 @@ test('page entry is recorded once under StrictMode; command rendering is separat render(); await screen.findByTestId('byo-mode-byo'); fireEvent.click(screen.getByTestId('byo-mode-byo')); await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: /get my connect command/i })); await screen.findByText(/your connect command for/i); - expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'hosted-offer-shown' }, { step: 'seat-created' }]); + expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'hosted-offer-shown' }, { step: 'seat-created', setupOs: 'mac', listenerAvailable: true }]); }); test('a failed clipboard write leaves the fallback available', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('Denied')) } }); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index bab529d6f6..55a1820ee3 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; -import { recordByoStep, type ByoStep } from '../utils/byoTelemetry'; +import { recordByoStep, type ByoCopyType, type ByoStep } from '../utils/byoTelemetry'; import getApiBaseUrl from '../../utils/apiBaseUrl'; import { daemonSetupCommand } from '../utils/daemonSetupCommand'; import { defaultMachine, machineDisplayName } from '../utils/machineLabel'; @@ -969,8 +969,10 @@ const V2AgentBYO: React.FC = () => { // BYO funnel telemetry: 7 of 7 recent seats died between token issuance and // the first authenticated call, and the server cannot see whether the user // even copied a command. Fire-and-forget; the flow never waits on it. - const byoStep = (step: ByoStep) => { - recordByoStep(api, step); + // Every step carries the setup the page is showing (OS and listener + // eligibility) so the server can tell "listener was possible" from "not". + const byoStep = (step: ByoStep, copyType?: ByoCopyType) => { + recordByoStep(api, step, { setupOs, listenerAvailable, ...(copyType ? { copyType } : {}) }); }; useEffect(() => { @@ -1000,8 +1002,9 @@ const V2AgentBYO: React.FC = () => { setShowHostedOffer(false); if (!issued) return; // This event means the issued command rendered, distinct from page entry. - recordByoStep(api, 'seat-created'); - }, [api, issued]); + // It also carries the setup the result page shows (TASK-714). + recordByoStep(api, 'seat-created', { setupOs, listenerAvailable }); + }, [api, issued, setupOs, listenerAvailable]); const rememberHostedOffer = () => { if (!offerSessionKey) return; @@ -1042,10 +1045,11 @@ const V2AgentBYO: React.FC = () => { copiedIssuedContent(); setCopied(key); setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); - if (key === 'claude' || key === 'cursor') byoStep('mcp-command-copied'); - else if (key === 'listen') byoStep('cli-command-copied'); - else if (key === 'daemon-setup') byoStep('daemon-command-copied'); - else if (key === 'tok') byoStep('token-copied'); + if (key === 'claude') byoStep('mcp-command-copied', 'mcp-claude'); + else if (key === 'cursor') byoStep('mcp-command-copied', 'mcp-cursor'); + else if (key === 'listen') byoStep('cli-command-copied', 'listen'); + else if (key === 'daemon-setup') byoStep('daemon-command-copied', 'daemon'); + else if (key === 'tok') byoStep('token-copied', 'token'); } catch { // Clipboard may be unavailable (non-HTTPS, sandbox); user can select manually. } diff --git a/frontend/src/v2/utils/byoTelemetry.ts b/frontend/src/v2/utils/byoTelemetry.ts index 66d074b7b2..e811980cbd 100644 --- a/frontend/src/v2/utils/byoTelemetry.ts +++ b/frontend/src/v2/utils/byoTelemetry.ts @@ -5,9 +5,21 @@ export type ByoStep = 'page-viewed' | 'seat-created' | 'mcp-command-copied' | 'c | 'hosted-offer-shown' | 'hosted-offer-clicked' | 'invite-card-shown' | 'invite-card-dismissed' | 'byo-offer-shown'; +// Which setup command was copied. Fixed names only; the server keeps the first +// copy of each (TASK-714), so "listen after MCP" stays visible. +export type ByoCopyType = 'listen' | 'mcp-claude' | 'mcp-cursor' | 'daemon' | 'token'; + +// What the result page showed: the OS it set up for and whether the listener +// could run there. Booleans and fixed names only, never the command or token. +export interface ByoSetupContext { + setupOs: 'mac' | 'linux' | 'windows' | 'other'; + listenerAvailable: boolean; + copyType?: ByoCopyType; +} + // Fixed event names only. Commands and credentials must never enter telemetry. -export const recordByoStep = (api: V2ApiClient, step: ByoStep): void => { - void api.post('/api/stats/byo-step', { step }).catch(() => {}); +export const recordByoStep = (api: V2ApiClient, step: ByoStep, context?: ByoSetupContext): void => { + void api.post('/api/stats/byo-step', { step, ...context }).catch(() => {}); }; // Surface steps fire on every mount; the server mark is write-once, so one From 1ffbd531d6d1267c11f79763d2af255c8c63419f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:44:40 -0700 Subject: [PATCH 2/2] fix(byo): a copy type must match its step (TASK-714 review) A token-copied step carrying copyType "listen" would have written the listen mark. Each copy step now accepts only the copy types it can carry. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/routes/stats.byo-step.test.js | 7 +++++++ backend/routes/stats.ts | 16 +++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/routes/stats.byo-step.test.js b/backend/__tests__/unit/routes/stats.byo-step.test.js index cdcba9076e..85be6ae3fd 100644 --- a/backend/__tests__/unit/routes/stats.byo-step.test.js +++ b/backend/__tests__/unit/routes/stats.byo-step.test.js @@ -133,6 +133,13 @@ describe('POST /api/stats/byo-step', () => { expect(mockInsertOne).not.toHaveBeenCalled(); }); + it('a copy type that does not match its step writes no per-type mark', async () => { + await request(app).post('/api/stats/byo-step').send({ step: 'token-copied', copyType: 'listen' }); + await request(app).post('/api/stats/byo-step').send({ step: 'cli-command-copied', copyType: 'mcp-claude' }); + + expect(mockMarkByoCopyType).not.toHaveBeenCalled(); + }); + it('rejects unknown steps without writing a mark or telemetry event', async () => { const res = await request(app) .post('/api/stats/byo-step') diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts index 973ff9e8aa..0326ce3489 100644 --- a/backend/routes/stats.ts +++ b/backend/routes/stats.ts @@ -91,8 +91,14 @@ const BYO_STEPS = new Set([ 'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked', 'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown', ]); -// Steps that copy a setup command; each carries the copy type the page sent. -const COPY_STEPS = new Set(['mcp-command-copied', 'cli-command-copied', 'daemon-command-copied', 'token-copied']); +// Steps that copy a setup command, and the copy types each may carry. A step +// and its type must agree, so "token-copied" can never set the listen mark. +const COPY_TYPES_BY_STEP: Record = { + 'mcp-command-copied': ['mcp-claude', 'mcp-cursor'], + 'cli-command-copied': ['listen'], + 'daemon-command-copied': ['daemon'], + 'token-copied': ['token'], +}; // Onboarding surfaces: each step also sets its write-once onboarding mark. const SURFACE_STEPS: Record = { 'invite-card-shown': 'inviteCardShownAt', @@ -116,9 +122,9 @@ router.post('/byo-step', rateLimit({ // the copied command, which contains a runtime token. markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli'); } - if (COPY_STEPS.has(step)) { - // The copy type is a fixed name the page sends; the service refuses anything else. - markByoCopyType(req.userId, req.body?.copyType); + const allowedCopyTypes = COPY_TYPES_BY_STEP[step]; + if (allowedCopyTypes && allowedCopyTypes.includes(req.body?.copyType)) { + markByoCopyType(req.userId, req.body.copyType); } // The setup the result page showed. The service validates both fields and // writes nothing for a missing or malformed value.