diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index 03fc8dfc91..90dbecf4a7 100644 --- a/docs/reference/telemetry.mdx +++ b/docs/reference/telemetry.mdx @@ -38,13 +38,15 @@ All telemetry events include basic system information: ## Disabling telemetry -To disable telemetry, set `XUM_DISABLE_TELEMETRY` before starting the app: +Toggle **Usage Telemetry** off in **Settings → General**. The change applies immediately (no restart) and persists as `telemetryEnabled: false` in the active Xum home's `config.json` — `~/.xum` by default, or the directory `XUM_ROOT` / an existing legacy `~/.mux` install points at. Opting out also drops a `telemetry_opt_out` marker file next to `config.json`, so the choice survives running an older Xum build whose settings writer doesn't know the field; toggling telemetry back on removes it. Builds that predate this toggle only honor the environment variable — if you opt out and plan to keep running such a build, also set `XUM_DISABLE_TELEMETRY=1`; the marker restores your choice for current builds once you upgrade again. + +Alternatively, set `XUM_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored): ```bash XUM_DISABLE_TELEMETRY=1 xum ``` -This disables telemetry collection at the backend level. +The environment variable is a hard override: when set to `1`, telemetry stays off regardless of the Settings toggle, and the toggle renders disabled with a note saying so. Both switches disable collection at the backend level. ## Source code diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index e0fa9a38cb..62cf59f40c 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -19,6 +19,8 @@ interface MockConfig { worktreeArchiveBehavior: WorktreeArchiveBehavior; chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; + telemetryEnabled: boolean; + telemetryDisabledByEnv: boolean; } interface MockAPIClient { @@ -30,6 +32,11 @@ interface MockAPIClient { }) => Promise; updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; + updateTelemetryEnabled: (input: { enabled: boolean }) => Promise; + onConfigChanged?: ( + input: undefined, + opts: { signal?: AbortSignal } + ) => Promise>; }; server: { getSshHost: () => Promise; @@ -41,7 +48,7 @@ interface MockAPIClient { }; } -let mockApi: MockAPIClient; +let mockApi: MockAPIClient | null; void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => { const SelectContext = React.createContext<{ @@ -171,6 +178,8 @@ interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; + telemetryEnabled?: boolean; + telemetryDisabledByEnv?: boolean; } interface MockAPISetup { @@ -187,6 +196,9 @@ interface MockAPISetup { updateChatTranscriptFullWidthMock: ReturnType< typeof mock<(input: { enabled: boolean }) => Promise> >; + updateTelemetryEnabledMock: ReturnType< + typeof mock<(input: { enabled: boolean }) => Promise> + >; } function createMockAPI(configOverrides: Partial = {}): MockAPISetup { @@ -195,6 +207,8 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, chatTranscriptFullWidth: false, llmDebugLogs: false, + telemetryEnabled: true, + telemetryDisabledByEnv: false, ...configOverrides, }; @@ -217,6 +231,12 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }); + const updateTelemetryEnabledMock = mock(({ enabled }: { enabled: boolean }) => { + config.telemetryEnabled = enabled; + + return Promise.resolve(); + }); + return { api: { config: { @@ -228,6 +248,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }), + updateTelemetryEnabled: updateTelemetryEnabledMock, }, server: { getSshHost: mock(() => Promise.resolve(null)), @@ -241,6 +262,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup getConfigMock, updateCoderPrefsMock, updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, }; } @@ -263,10 +285,21 @@ describe("GeneralSection", () => { }); function renderGeneralSection(options: RenderGeneralSectionOptions = {}) { - const { api, updateCoderPrefsMock, updateChatTranscriptFullWidthMock } = createMockAPI({ + const { + api, + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, + } = createMockAPI({ chatTranscriptFullWidth: options.chatTranscriptFullWidth, coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior, worktreeArchiveBehavior: options.worktreeArchiveBehavior, + ...(options.telemetryEnabled !== undefined + ? { telemetryEnabled: options.telemetryEnabled } + : {}), + ...(options.telemetryDisabledByEnv !== undefined + ? { telemetryDisabledByEnv: options.telemetryDisabledByEnv } + : {}), }); mockApi = api; @@ -276,7 +309,12 @@ describe("GeneralSection", () => { ); - return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view }; + return { + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, + view, + }; } function getSelectTrigger(view: ReturnType, label: string): HTMLElement { @@ -353,6 +391,405 @@ describe("GeneralSection", () => { }); }); + test("loads the telemetry opt-out and persists re-enabling it", async () => { + const { updateTelemetryEnabledMock, view } = renderGeneralSection({ + telemetryEnabled: false, + }); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // A persisted opt-out must render unchecked (default is enabled). + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: true }); + }); + }); + + test("renders the telemetry switch hard-disabled when the environment overrides it", async () => { + const { updateTelemetryEnabledMock, view } = renderGeneralSection({ + telemetryEnabled: true, + telemetryDisabledByEnv: true, + }); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // Env override wins over the config value: switch shows off and cannot be flipped. + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.hasAttribute("disabled")).toBe(true); + }); + expect(view.getByText(/Disabled by the environment/i)).toBeTruthy(); + + fireEvent.click(toggle); + expect(updateTelemetryEnabledMock).not.toHaveBeenCalled(); + }); + + test("reverts the telemetry switch when persisting the change fails", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: true }); + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation(() => + Promise.reject(new Error("write failed")) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + fireEvent.click(toggle); + + // A privacy control must not read "off" while the backend still collects: + // the failed write reloads the backend truth (still enabled). + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: false }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("syncs the telemetry switch when another client changes the config", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api } = setup; + + // Drivable config-change stream: pushEvent() delivers one notification. + let pushEvent: (() => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEvent = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEvent).toBeDefined(); + }); + + // Another window persists an opt-out; this pane only learns via the stream. + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: false, + telemetryDisabledByEnv: false, + }) + ); + pushEvent?.(); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + }); + + test("re-syncs telemetry state for changes that land before the subscription connects", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api } = setup; + + // Hold the subscription unestablished so a config change can land in the + // gap between the initial snapshot and the listener coming online. + let resolveSubscribe: ((generator: AsyncGenerator) => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => + new Promise>((resolve) => { + resolveSubscribe = resolve; + }); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(resolveSubscribe).toBeDefined(); + }); + + // Another client opts out while this pane has no listener yet. + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: false, + telemetryDisabledByEnv: false, + }) + ); + + // Connecting the subscription must trigger a re-sync — no event is ever + // pushed for the change that already happened. + resolveSubscribe?.( + (async function* () { + await new Promise(() => { + // Never yields; the post-connect refresh is what syncs. + }); + yield {}; + })() + ); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + }); + + test("replays a config notification that arrived while a local write was in flight", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api, updateTelemetryEnabledMock } = setup; + + let pushEvent: (() => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEvent = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + + let resolveUpdate: (() => void) | undefined; + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEvent).toBeDefined(); + }); + + // Local opt-out is in flight when another client re-enables telemetry. + fireEvent.click(toggle); + await waitFor(() => { + expect(resolveUpdate).toBeDefined(); + }); + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: true, + telemetryDisabledByEnv: false, + }) + ); + pushEvent?.(); + + // The notification must not be dropped: once the write settles, the pane + // reconciles against the shared config (the other client's enable won). + resolveUpdate?.(); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("replays a deferred notification through the replacement API client", async () => { + const setupA = createMockAPI({ telemetryEnabled: true }); + const apiA = setupA.api; + + let pushEventA: (() => void) | undefined; + apiA.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEventA = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + + let rejectWriteA: ((error: Error) => void) | undefined; + apiA.config.updateTelemetryEnabled = setupA.updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectWriteA = reject; + }) + ); + mockApi = apiA; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEventA).toBeDefined(); + }); + + // Local opt-out in flight on client A; a change notification arrives and + // is deferred behind the pending write. + fireEvent.click(toggle); + await waitFor(() => { + expect(rejectWriteA).toBeDefined(); + }); + pushEventA?.(); + + // APIProvider replaces the client while the old write is still pending. + // The replacement's config says telemetry is enabled (the other client's + // enable won). + const setupB = createMockAPI({ telemetryEnabled: true }); + mockApi = setupB.api; + view.rerender( + + + + ); + + // The old write settles AFTER the replacement: the deferred notification + // must replay through client B, not the disconnected client A. + rejectWriteA?.(new Error("connection dropped")); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("disables the telemetry switch while the API is unavailable", () => { + // Browser-mode outage: APIProvider keeps settings mounted with api: null. + mockApi = null; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // A privacy toggle must not accept a change it cannot deliver: the switch + // is disabled and a click leaves the conservative ON state untouched. + expect(toggle.hasAttribute("disabled")).toBe(true); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + test("renders the telemetry switch ON when backend truth is unreachable after a failed write", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: true }); + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation(() => + Promise.reject(new Error("connection dropped")) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + // After the initial load, make the reconciliation getConfig fail too, so + // the disable attempt ends with no confirmed backend state. + api.config.getConfig = mock(() => Promise.reject(new Error("connection dropped"))); + + fireEvent.click(toggle); + + // Indeterminate outcome must render ON: the disable may not have landed, + // and a privacy switch must not read "off" while collection may continue. + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: false }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("a superseded telemetry write failure does not clobber the latest choice", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: false }); + const deferred: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((resolve, reject) => { + deferred.push({ resolve, reject }); + }) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + + // Rapid on → off → on; writes are serialized so only the first is in flight. + fireEvent.click(toggle); + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + await waitFor(() => { + expect(deferred.length).toBe(1); + }); + + // The first write fails only after later intents were queued: its failure + // handling is superseded and must not touch the switch. + deferred[0].reject(new Error("write failed")); + + await waitFor(() => { + expect(deferred.length).toBe(2); + }); + deferred[1].resolve(); + await waitFor(() => { + expect(deferred.length).toBe(3); + }); + deferred[2].resolve(); + + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledTimes(3); + expect(updateTelemetryEnabledMock).toHaveBeenLastCalledWith({ enabled: true }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 5eaf67cb33..c08389f94a 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -59,6 +59,7 @@ import { isWorktreeArchiveBehavior, type WorktreeArchiveBehavior, } from "@/common/config/worktreeArchiveBehavior"; +import { XUM_PRODUCT_NAME } from "@/common/constants/product"; function getTerminalFontAvailabilityWarning(config: TerminalFontConfig): string | undefined { if (typeof document === "undefined") { @@ -232,6 +233,11 @@ export function GeneralSection() { const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); + // Optimistic default: telemetry is on unless config says otherwise. + const [telemetryEnabled, setTelemetryEnabled] = useState(true); + // Env hard-off (XUM_DISABLE_TELEMETRY, CI): the switch renders disabled + // instead of pretending the config toggle controls anything. + const [telemetryDisabledByEnv, setTelemetryDisabledByEnv] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); const worktreeArchiveBehaviorRef = useRef( @@ -240,12 +246,51 @@ export function GeneralSection() { const chatTranscriptFullWidthLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); + const telemetryEnabledLoadNonceRef = useRef(0); + // Monotonic id per telemetry toggle; failure handling may only touch state + // while its own intent is still the latest. + const telemetryEnabledIntentRef = useRef(0); + // Writes still in flight (including their failure reconciliation). Config + // change notifications are deferred while > 0 — NOT dropped: the backend + // emits onConfigChanged before the RPC resolves, so even our own final + // write's notification can arrive while this counter is positive, and an + // external change during the write window would otherwise be lost. + const telemetryEnabledPendingWritesRef = useRef(0); + // Set when a notification was deferred; drained (with a refresh) when the + // pending-writes counter reaches zero. + const telemetryEnabledMissedNotificationRef = useRef(false); + + // Re-read the persisted telemetry state and apply it unless a newer local + // action (toggle or later refresh) superseded this read. + const refreshTelemetryFromBackend = async () => { + if (!api?.config?.getConfig) { + return; + } + const nonce = ++telemetryEnabledLoadNonceRef.current; + try { + const cfg = await api.config.getConfig(); + if (nonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + setTelemetryDisabledByEnv(cfg.telemetryDisabledByEnv === true); + } + } catch { + // Notifications are edge-triggered: with no later refresh guaranteed, a + // failed read must not strand the switch. Indeterminate state renders + // ON — showing "off" while collection may have resumed is the one lie a + // privacy toggle can't tell (same doctrine as the toggle failure path). + // The nonce guard keeps a newer local action authoritative. + if (nonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(true); + } + } + }; // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const chatTranscriptFullWidthUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); + const telemetryEnabledUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined ); @@ -262,6 +307,7 @@ export function GeneralSection() { const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; + const telemetryEnabledNonce = ++telemetryEnabledLoadNonceRef.current; void api.config .getConfig() @@ -297,6 +343,11 @@ export function GeneralSection() { if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } + + if (telemetryEnabledNonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + setTelemetryDisabledByEnv(cfg.telemetryDisabledByEnv === true); + } }) .catch(() => { if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { @@ -431,6 +482,147 @@ export function GeneralSection() { }); }; + const handleTelemetryEnabledChange = (checked: boolean) => { + // No usable API (browser-mode outage): don't flip optimistically — the + // switch would render OFF with no write ever issued while the backend may + // keep collecting, silently discarding the intent. The switch itself is + // also disabled while api is null; this guard covers the race where the + // connection drops between render and click. + if (!api?.config?.updateTelemetryEnabled) { + return; + } + + // Invalidate any in-flight config load so it doesn't overwrite the user's selection. + telemetryEnabledLoadNonceRef.current++; + setTelemetryEnabled(checked); + + const intent = ++telemetryEnabledIntentRef.current; + telemetryEnabledPendingWritesRef.current++; + + // Serialize writes so rapid toggles always persist the last user choice. + telemetryEnabledUpdateChainRef.current = telemetryEnabledUpdateChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(() => api.config.updateTelemetryEnabled({ enabled: checked })) + .then(() => { + // Coerce the chain back to Promise. + }) + .catch(async () => { + // A privacy control must never read "off" while collection continues. + // A superseded request's failure is not ours to handle — a later write + // in the chain carries the newest choice and its own handling. For the + // latest intent, reload the backend truth rather than guessing with a + // blind flip (earlier writes in the chain may themselves have failed). + if (telemetryEnabledIntentRef.current !== intent) { + return; + } + try { + const cfg = await api.config.getConfig(); + if (telemetryEnabledIntentRef.current === intent) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + } + } catch { + if (telemetryEnabledIntentRef.current === intent) { + // Backend truth is unreachable (e.g. the connection dropped after + // the request may already have persisted and applied). Indeterminate + // state must render as ON: showing "off" while telemetry might be + // collecting is the one lie a privacy toggle can't tell. The next + // successful config load reconciles the real value. + setTelemetryEnabled(true); + } + } + }) + .finally(() => { + telemetryEnabledPendingWritesRef.current--; + // Replay a notification that arrived during the write window: the + // backend may have changed under us (another client, or our own write + // whose notification fired before the RPC resolved). Replays go + // through the ref so they use the CURRENT api generation — this + // callback can outlive an API replacement. + if ( + telemetryEnabledPendingWritesRef.current === 0 && + telemetryEnabledMissedNotificationRef.current + ) { + telemetryEnabledMissedNotificationRef.current = false; + refreshTelemetryRef.current(); + } + }); + }; + + // Always points at the CURRENT api generation's refresh: settle-replay + // callbacks from old writes outlive an API replacement and must not replay + // through the disconnected client they captured (a failed read there would + // consume the deferred notification and strand the switch stale). + const refreshTelemetryRef = useRef<() => void>(() => { + // No-op until the api effect installs the real refresh. + }); + + // An API replacement (browser-mode reconnect) obsoletes in-flight telemetry + // writes made through the previous client: invalidate their pending intents + // so a late rejection from the old client can't run failure reconciliation + // against state the new client has since confirmed. The subscription effect + // below re-establishes on the new client and re-syncs on connect. + useEffect(() => { + telemetryEnabledIntentRef.current++; + refreshTelemetryRef.current = () => void refreshTelemetryFromBackend(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTelemetryFromBackend only closes over `api` (the dep) and stable refs/setters. + }, [api]); + + // Cross-client telemetry sync: another window/tab (or the API server) can + // flip the toggle; consume the config-change stream so this pane's switch + // tracks the true collection state instead of showing a stale value. + useEffect(() => { + if (!api?.config?.onConfigChanged) { + return; + } + const abortController = new AbortController(); + const signal = abortController.signal; + let iterator: AsyncIterator | null = null; + + const refreshTelemetry = () => { + // Defer (never drop) while our own writes are in flight: the settle + // handler replays the refresh once the queue drains. + if (telemetryEnabledPendingWritesRef.current > 0) { + telemetryEnabledMissedNotificationRef.current = true; + return; + } + void refreshTelemetryFromBackend(); + }; + + const subscription = (async () => { + try { + const subscribedIterator = await api.config.onConfigChanged(undefined, { signal }); + if (signal.aborted) { + const cleanup = subscribedIterator.return?.(); + cleanup?.catch(() => undefined); + return; + } + iterator = subscribedIterator; + // The initial config snapshot raced this subscription's establishment: + // a change landing in that gap had no listener and would leave the + // switch stale until the next unrelated edit. Re-sync once connected. + refreshTelemetry(); + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + void refreshTelemetry(); + } + } catch { + // Config subscriptions are cancelled during unmounts and API reconnects. + } + })(); + subscription.catch(() => undefined); + + return () => { + abortController.abort(); + const cleanup = iterator?.return?.(undefined); + cleanup?.catch(() => undefined); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTelemetryFromBackend only closes over `api` (already a dep) and stable refs/setters. + }, [api]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -856,6 +1048,43 @@ export function GeneralSection() { +
+

Privacy

+
+
+
+
Usage Telemetry
+
+ Send anonymous usage events to help improve {XUM_PRODUCT_NAME} — no code, paths, or + prompts.{" "} + + What is collected + + {telemetryDisabledByEnv && ( + + Disabled by the environment (XUM_DISABLE_TELEMETRY / CI) — this switch has no + effect until that is removed. + + )} +
+
+ +
+
+
+
Editor
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index d2e8981c72..2b8fd79af1 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -155,6 +155,8 @@ export interface MockORPCClientOptions { agentAiDefaults?: AgentAiDefaults; /** Agent definitions to expose via agents.list */ agentDefinitions?: AgentDefinitionDescriptor[]; + /** Initial telemetry opt-in state for config.getConfig (Settings → General → Privacy) */ + telemetryEnabled?: boolean; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** What to do with xum-managed worktrees when archiving a chat. */ @@ -403,6 +405,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl userPreferences: initialUserPreferences, taskSettings: initialTaskSettings, agentAiDefaults: initialAgentAiDefaults, + telemetryEnabled: initialTelemetryEnabled, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, @@ -639,6 +642,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; + let telemetryEnabled = initialTelemetryEnabled ?? true; const mockStats: ChatStats = { consumers: [], @@ -781,6 +785,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, + telemetryEnabled, + telemetryDisabledByEnv: false, }), saveConfig: (input: { taskSettings?: unknown; @@ -826,6 +832,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateTelemetryEnabled: (input: { enabled: boolean }) => { + telemetryEnabled = input.enabled; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateMuxGatewayPrefs: (input: { muxGatewayEnabled: boolean; muxGatewayModels: string[]; diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index a17311df90..063e4e6d9a 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -93,6 +93,7 @@ export const CommandIds = { // Settings commands settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, + telemetryToggle: () => "settings:telemetry:toggle" as const, coderDisconnect: () => "providers:coder:disconnect" as const, coderRefreshModels: () => "providers:coder:refresh-models" as const, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 94fc01a220..105b76b240 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -223,6 +223,13 @@ const getAnalyticsRebuildDatabase = ( return typeof rebuildDatabase === "function" ? rebuildDatabase : null; }; +// Serializes Toggle Usage Telemetry read-modify-writes: palette invocations +// start actions without awaiting them, so two rapid toggles would otherwise +// both read the same value and write the same inverse, collapsing two +// requested transitions into one. Each queued run observes its predecessor's +// persisted result. +let telemetryTogglePending: Promise = Promise.resolve(); + const showCommandFeedbackToast = (feedback: { type: "success" | "error"; message: string; @@ -1900,6 +1907,57 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi ]); } + // Telemetry toggle: keyboard-reachable twin of the Settings -> General + // switch (every user operation must be invocable from the palette). Calls + // the RPC directly so it works even when the Settings pane never opened. + if (p.api) { + const apiForTelemetry = p.api; + actions.push(() => [ + { + id: CommandIds.telemetryToggle(), + title: "Toggle Usage Telemetry", + subtitle: "Anonymous usage analytics (Settings -> General)", + section: section.settings, + keywords: ["telemetry", "analytics", "privacy", "usage", "tracking", "opt out"], + run: () => { + const task = telemetryTogglePending.then(async () => { + // Read fresh backend truth instead of any cached UI state: the + // palette can run with Settings closed, and a privacy toggle must + // flip the real persisted value. + const cfg = await apiForTelemetry.config.getConfig(); + if (cfg.telemetryDisabledByEnv === true) { + showCommandFeedbackToast({ + type: "error", + message: + "Telemetry is hard-disabled by the environment (XUM_DISABLE_TELEMETRY, CI, or tests); the toggle has no effect.", + }); + return; + } + const next = cfg.telemetryEnabled === false; + await apiForTelemetry.config.updateTelemetryEnabled({ enabled: next }); + showCommandFeedbackToast({ + type: "success", + message: next ? "Usage telemetry enabled." : "Usage telemetry disabled.", + }); + }); + telemetryTogglePending = task.then( + () => undefined, + () => { + // A privacy control must never fail silently: without feedback + // the user cannot tell whether collection state changed. The + // coerced chain stays usable for the next invocation. + showCommandFeedbackToast({ + type: "error", + message: "Could not toggle usage telemetry - the backend is unreachable.", + }); + } + ); + return telemetryTogglePending; + }, + }, + ]); + } + // Coder disconnect: calls the RPC directly (no settings UI needed), so it is // not gated on onOpenSettings like the section-opening commands above. actions.push(() => [ diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 4a26dc8145..0b4defa9d5 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -145,6 +145,12 @@ export const AppConfigOnDiskSchema = z chatTranscriptFullWidth: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), llmDebugLogs: z.boolean().optional(), + /** + * Anonymous usage telemetry opt-out (Settings → General). Absent/true = + * enabled; false = disabled. MUX_DISABLE_TELEMETRY=1 also hard-disables + * regardless of this field. + */ + telemetryEnabled: z.boolean().optional(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z .number() diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8a87a56e0f..83913cd2f6 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2547,6 +2547,11 @@ export const config = { muxGovernorEnrolled: z.boolean(), chatTranscriptFullWidth: z.boolean(), llmDebugLogs: z.boolean(), + telemetryEnabled: z.boolean(), + // True when the environment (MUX_DISABLE_TELEMETRY, CI, tests) hard-disables + // telemetry regardless of the config toggle — the UI renders the switch + // disabled instead of pretending it controls anything. + telemetryDisabledByEnv: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), goalDefaults: GoalDefaultsConfigSchema, @@ -2634,6 +2639,7 @@ export const config = { }, updateChatTranscriptFullWidth: booleanToggleRoute, updateLlmDebugLogs: booleanToggleRoute, + updateTelemetryEnabled: booleanToggleRoute, updateHeartbeatDefaultPrompt: { input: z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index f3011a9a09..eb60095c69 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -92,6 +92,8 @@ export interface ProjectsConfig { muxGatewayEnabled?: boolean; /** Enable recording AI SDK devtools logs to ~/.xum/sessions//devtools.jsonl */ llmDebugLogs?: boolean; + /** Anonymous usage telemetry opt-out: absent/true = enabled, false = disabled. */ + telemetryEnabled?: boolean; /** Default heartbeat prompt used when a workspace heartbeat does not set its own message. */ heartbeatDefaultPrompt?: string; /** Default heartbeat interval used when a workspace heartbeat does not set its own cadence. */ diff --git a/src/node/config.telemetryEnabled.test.ts b/src/node/config.telemetryEnabled.test.ts new file mode 100644 index 0000000000..bdfcf76098 --- /dev/null +++ b/src/node/config.telemetryEnabled.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as fsSync from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { Config } from "@/node/config"; + +// chmod-based error injection is meaningless where permission bits don't +// bind: root bypasses them (common in containerized CI) and Windows ACLs +// ignore POSIX modes entirely. +const permissionBitsEnforced = + process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() !== 0; + +describe("Config telemetryEnabled persistence", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-telemetry-enabled-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("fails closed when config.json exists but cannot be parsed", async () => { + const config = new Config(tempDir); + // Fresh install (no file) is not an error: telemetry stays enabled. + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + // A corrupted file must not silently override a possible opt-out: + // unreadable persisted state reports disabled. + await fs.writeFile(path.join(tempDir, "config.json"), "{ not json", "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it.skipIf(!permissionBitsEnforced)( + "fails closed when the config directory is inaccessible", + async () => { + const config = new Config(tempDir); + await fs.writeFile(path.join(tempDir, "config.json"), JSON.stringify({}), "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + // existsSync() masks EACCES as "missing"; the stat-based check must treat + // an unreachable ~/.mux as a possible opt-out, not as enabled-by-default. + await fs.chmod(tempDir, 0o000); + try { + expect(config.isTelemetryDisabledByConfig()).toBe(true); + } finally { + await fs.chmod(tempDir, 0o700); + } + } + ); + + it("fails closed when telemetryEnabled is present but not a boolean", async () => { + const config = new Config(tempDir); + // Valid JSON with a corrupted field: parse succeeds, so the unreadable-file + // guard never fires — the field itself must read as disabled, not as an + // absent opt-out that re-enables telemetry. + for (const corrupted of ['"false"', "null", "0", '"yes"']) { + await fs.writeFile( + path.join(tempDir, "config.json"), + `{ "telemetryEnabled": ${corrupted} }`, + "utf-8" + ); + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + } + + // A well-formed value keeps its meaning in both directions. + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": true }`, "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + }); + + it("reconciles a crash-split preference from the explicit field on startup", async () => { + const config = new Config(tempDir); + const markerPath = path.join(tempDir, "telemetry_opt_out"); + + // Crash after the verified opt-out write, before the marker sync: the + // explicit field recreates the missing marker. + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": false }`, "utf-8"); + await config.reconcileTelemetryOptOutMarker(); + expect(fsSync.existsSync(markerPath)).toBe(true); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + + // Hand-declared re-enable (explicit true) removes a stale marker. + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": true }`, "utf-8"); + await config.reconcileTelemetryOptOutMarker(); + expect(fsSync.existsSync(markerPath)).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + // Absent field + marker is the downgrade-survivor state: reconciliation + // must NOT remove the marker (crash-mid-enable is indistinguishable, and + // fail-closed is the privacy-safe direction). + await config.setTelemetryEnabledPersisted(false); + await fs.writeFile(path.join(tempDir, "config.json"), "{}", "utf-8"); + await config.reconcileTelemetryOptOutMarker(); + expect(fsSync.existsSync(markerPath)).toBe(true); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it("keeps the opt-out when an older build's save drops the field (marker backstop)", async () => { + const config = new Config(tempDir); + config.setTelemetryOptOutMarker(true); + + // Simulate the downgrade round-trip: an older build's whitelist-based + // saveConfig rewrites config.json without the (to it unknown) field. + await fs.writeFile(path.join(tempDir, "config.json"), "{}", "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + + // Re-enabling clears the marker: absent field + no marker reads enabled. + config.setTelemetryOptOutMarker(false); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + }); + + it("a telemetry toggle does not deadlock against an in-flight unrelated edit", async () => { + const config = new Config(tempDir); + // Codex P1 regression: an ordinary editConfig mid-save while the telemetry + // section starts. The section must WAIT for the file lock (never enter as + // a pass-through of a queue-turn edit), and once it owns the lock its own + // nested edits must bypass the queue — either mistake wedges both callers. + const withSave = config as unknown as { saveConfig: (c: unknown) => Promise }; + const originalSave = withSave.saveConfig.bind(config); + let firstSave = true; + const saveSpy = spyOn(withSave, "saveConfig").mockImplementation(async (c: unknown) => { + if (firstSave) { + firstSave = false; + // Hold the first edit's lock across the toggle's arrival. + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return originalSave(c); + }); + try { + await Promise.all([ + config.editConfig((cfg) => ({ ...cfg, chatTranscriptFullWidth: true })), + config.setTelemetryEnabledPersisted(false), + ]); + } finally { + saveSpy.mockRestore(); + } + + expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBe(true); + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it("acquires the write lock on a fresh home by creating the root first", async () => { + // ENOENT on the lock mkdir means the home itself is missing (first run) — + // that must create the root and lock, not silently run unlocked. + const freshRoot = path.join(tempDir, "nested", "fresh-home"); + const config = new Config(freshRoot); + + await config.setTelemetryEnabledPersisted(false); + + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(fsSync.existsSync(path.join(freshRoot, "telemetry_opt_out"))).toBe(true); + // The lock released cleanly. + expect(fsSync.existsSync(path.join(freshRoot, "config_write.lock"))).toBe(false); + }); + + it("notifies clients again after the marker sync completes", async () => { + const config = new Config(tempDir); + await config.setTelemetryEnabledPersisted(false); + expect(fsSync.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(true); + + // The nested field edit notifies before the marker is touched; a peer + // reading marker-aware state on that early event would still see the old + // effective value, so a final post-transaction notification must fire + // once the marker agrees with the field. + const markerStateAtNotify: boolean[] = []; + const notifiable = config as unknown as { notifyConfigChanged: () => void }; + const originalNotify = notifiable.notifyConfigChanged.bind(config); + const notifySpy = spyOn(notifiable, "notifyConfigChanged").mockImplementation(() => { + markerStateAtNotify.push(fsSync.existsSync(path.join(tempDir, "telemetry_opt_out"))); + originalNotify(); + }); + try { + await config.setTelemetryEnabledPersisted(true); + } finally { + notifySpy.mockRestore(); + } + + expect(markerStateAtNotify.length).toBeGreaterThanOrEqual(2); + // The last notification observes the completed transaction: marker gone. + expect(markerStateAtNotify[markerStateAtNotify.length - 1]).toBe(false); + }); + + it("round-trips the opt-out through editConfig saves and reports it", async () => { + const config = new Config(tempDir); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + await config.editConfig((cfg) => ({ ...cfg, telemetryEnabled: false })); + + // A fresh instance re-reads from disk: the field must survive the + // whitelist-based saveConfig serialization. + const reloaded = new Config(tempDir); + expect(reloaded.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(reloaded.isTelemetryDisabledByConfig()).toBe(true); + + // Clearing the field (re-enable) must persist too. + await reloaded.editConfig((cfg) => ({ ...cfg, telemetryEnabled: undefined })); + const cleared = new Config(tempDir); + expect(cleared.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(cleared.isTelemetryDisabledByConfig()).toBe(false); + }); +}); diff --git a/src/node/config/fileLeaseManager.ts b/src/node/config/fileLeaseManager.ts index 9a52bd05cb..c20610f6aa 100644 --- a/src/node/config/fileLeaseManager.ts +++ b/src/node/config/fileLeaseManager.ts @@ -15,6 +15,16 @@ function isProcessAlive(pid: number): boolean { } } +/** + * Markers this PROCESS failed to unlink at release; safe to self-reclaim. + * Module-scope on purpose: lease-manager instances can be short-lived (the + * config tool's document writer constructs one per write), and an + * instance-held record would vanish with its object while the orphaned marker + * keeps naming this live pid — every other instance would then honor it as a + * live owner until process exit. + */ +const unreleasedDirLockMarkers = new Set(); + /** * Directory locks are installed by atomically renaming a staged directory with a * generation marker. Empty directories are release or crash remnants. Live owners @@ -37,6 +47,24 @@ export class FileLeaseManager { return this.withDirLock(`${this.providersFile}.lock`, 5_000, 10_000, fn); } + /** + * Serializes config.json writers cross-process: every Config editConfig + * load->save, the telemetry field/verification/marker transaction, and the + * mux_config_write document writer. Same 45s/60s policy as the + * coder-refresh lease. + */ + async withConfigWriteLock( + fn: () => Promise | T, + options?: { acquireTimeoutMs?: number } + ): Promise { + return this.withDirLock( + path.join(this.rootDir, "config_write.lock"), + options?.acquireTimeoutMs ?? 45_000, + 60_000, + fn + ); + } + /** Serializes rotating-token refreshes so losers adopt the persisted winner. */ async withCoderOauthRefreshLock(fn: () => Promise | T): Promise { return this.withDirLock(`${this.providersFile}.coder-refresh.lock`, 45_000, 60_000, fn); @@ -64,10 +92,21 @@ export class FileLeaseManager { const code = (error as NodeJS.ErrnoException).code; // POSIX rename refuses a non-empty target with ENOTEMPTY (some // platforms report EEXIST); Windows refuses any existing target with - // EPERM/EEXIST. - if (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") { + // EPERM/EEXIST. EPERM counts as contention only when the target lock is + // actually present: a policy/antivirus denial with NO lock in place + // would otherwise alternate with the breaker's ENOENT "retry now" + // answer into a no-await spin for the whole acquisition budget. + if (code === "EEXIST" || code === "ENOTEMPTY") { return null; } + if (code === "EPERM") { + try { + fs.lstatSync(lockPath); + return null; + } catch { + throw error; + } + } throw error; } return path.join(lockPath, markerName); @@ -138,18 +177,59 @@ export class FileLeaseManager { try { return await fn(); } finally { + // Primary release: atomically rename the WHOLE generation aside. One + // syscall vacates the lock path, so peer processes see ENOENT + // immediately and no partial unlink/rmdir state (empty dir, marker + // naming a live pid) is ever observable there — the failure mode that + // otherwise stalls PEERS, which cannot see this process's in-memory + // orphan registry. The owner check keeps a stale-broken holder's hands + // off a successor's generation, and the renamed remains ride the + // `.stage-` TTL sweeper when the immediate delete fails. + let renamedAside = false; try { - fs.unlinkSync(ownerFile); + fs.statSync(ownerFile); + const releasedPath = `${lockPath}.stage-rel-${crypto.randomBytes(8).toString("hex")}`; + fs.renameSync(lockPath, releasedPath); + renamedAside = true; try { - fs.rmdirSync(lockPath); - } catch (error) { - // ENOENT/ENOTEMPTY: a breaker finished the removal or a successor - // generation already acquired the path; leave it to them. - log.debug("Failed to release providers config lock:", error); + fs.rmSync(releasedPath, { recursive: true, force: true }); + } catch { + // Swept later by cleanupAbandonedStageDirs. } } catch { - // Marker already gone: this holder outlived staleLockMs and was - // stale-broken; a successor may hold the lock now; keep it. + // ENOENT on the owner stat: stale-broken, a successor may own the + // path — keep it. Any rename failure falls through to the + // unlink-based release below. + } + if (!renamedAside) { + try { + fs.unlinkSync(ownerFile); + try { + fs.rmdirSync(lockPath); + } catch (error) { + // ENOENT/ENOTEMPTY: a breaker finished the removal or a successor + // generation already acquired the path; leave it to them. + log.debug("Failed to release dir lock:", error); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // Marker already gone: this holder outlived staleLockMs and was + // stale-broken; a successor may hold the lock now; keep it. + } else { + // Both release strategies failed (EIO/ESTALE): the marker still + // names this LIVE pid, so the breaker's owner-alive rule would + // treat the finished hold as a live owner and stall every later + // acquisition until process exit. Record the orphan so this + // process's own breaker may reclaim it — marker names are + // generation-unique, so the registry can never match a lock a + // concurrent holder in this process currently owns. + unreleasedDirLockMarkers.add(ownerFile); + log.error("Failed to release dir lock marker; marked for self-reclaim", { + ownerFile, + error, + }); + } + } } } } @@ -172,8 +252,18 @@ export class FileLeaseManager { try { const installed = this.tryInstallDirLock(leasePath); if (installed == null) { - // Contended: held by another flow (or a crash remnant). - if (!this.tryBreakStaleDirLock(leasePath, ttlMs)) { + // Contended: held by another flow (or a crash remnant). The breaker + // now propagates inspection/cleanup failures (spin protection for + // the locking callers); this lease's contract is degrade-to-null, + // so treat those failures as "held". + let broke: boolean; + try { + broke = this.tryBreakStaleDirLock(leasePath, ttlMs); + } catch (error) { + log.debug("Failed to inspect Coder OAuth client lease:", error); + return null; + } + if (!broke) { return null; // Held by a live flow. } continue; // Stale lease broken (or it vanished); retry once. @@ -212,8 +302,15 @@ export class FileLeaseManager { let entries: string[]; try { entries = fs.readdirSync(leasePath); - } catch { - return true; // Released between the failed mkdir and now; retry. + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return true; // Released between the failed mkdir and now; retry. + } + // EACCES/EIO/ESTALE: the lock exists but cannot be inspected. Returning + // "retry" would spin the caller's no-await fast path against the same + // error until its acquisition deadline — a synchronous event-loop + // freeze. Surface the inspection failure instead. + throw error; } const isStale = (mtimeMs: number) => Date.now() - mtimeMs > ttlMs; @@ -222,9 +319,15 @@ export class FileLeaseManager { // removal cannot delete a concurrently installed generation. try { fs.rmdirSync(leasePath); - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; // ENOTEMPTY (a generation was renamed into place) or ENOENT (another // breaker won); the retried install/staleness check sorts either out. + // Anything else (EACCES/EIO on an undeletable orphan) must propagate: + // "retry" would spin the no-await fast path to its deadline. + if (code !== "ENOTEMPTY" && code !== "ENOENT") { + throw error; + } } return true; } @@ -242,11 +345,23 @@ export class FileLeaseManager { try { const content = fs.readFileSync(entryPath, "utf8").trim(); ownerPid = /^\d+$/.test(content) ? Number(content) : null; - } catch { - continue; // Vanished mid-check; the conditional cleanup below is safe. + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; // Vanished mid-check; the conditional cleanup below is safe. + } + // Like an unreadable lock dir: "retry" would spin the caller's + // no-await fast path. Surface the inspection failure. + throw error; } if (ownerPid !== null) { - if (isProcessAlive(ownerPid)) { + if (unreleasedDirLockMarkers.has(entryPath)) { + // Our own completed hold failed to unlink this marker: the pid is + // alive (it is us) but the hold is over — reclaim instead of + // treating ourselves as a live owner forever. The registry entry is + // removed only below, once the unlink provably succeeded (or the + // marker is confirmed absent): a second transient failure must not + // consume the one record that makes the orphan reclaimable. + } else if (isProcessAlive(ownerPid)) { return false; } } else { @@ -254,19 +369,34 @@ export class FileLeaseManager { if (!isStale(fs.statSync(entryPath).mtimeMs)) { return false; } - } catch { - continue; // Vanished mid-check; the conditional cleanup below is safe. + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; // Vanished mid-check; the conditional cleanup below is safe. + } + throw error; } } try { fs.unlinkSync(entryPath); - } catch { - // Already removed by a concurrent breaker or by its owner's release. + unreleasedDirLockMarkers.delete(entryPath); + } catch (error) { + // ENOENT: already removed by a concurrent breaker or the owner's + // release. An undeletable stale marker (EACCES/EIO) must propagate — + // returning "retry" here would spin the no-await fast path — and a + // registered orphan keeps its record for the next reclaim attempt. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + unreleasedDirLockMarkers.delete(entryPath); } } try { fs.rmdirSync(leasePath); - } catch { + } catch (error) { + const finalCode = (error as NodeJS.ErrnoException).code; + if (finalCode !== "ENOTEMPTY" && finalCode !== "ENOENT") { + throw error; + } // ENOTEMPTY (a generation appeared) or ENOENT (another breaker won); // the retried mkdir/staleness check sorts either out. } diff --git a/src/node/config/index.ts b/src/node/config/index.ts index c44bb04cb8..87ff580ac5 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -270,6 +270,19 @@ function parseOptionalBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +/** + * Privacy opt-outs fail CLOSED on corruption: a present-but-invalid + * telemetryEnabled (valid JSON, wrong type — "false", 0, null) must read as + * disabled, not as an absent opt-out that silently resumes telemetry. Only a + * genuinely absent field keeps the enabled-by-default semantics. + */ +function parseTelemetryEnabled(value: unknown): boolean | undefined { + if (value === undefined) { + return undefined; + } + return value === true; +} + function parseUpdateChannel(value: unknown): UpdateChannel | undefined { if (value === "stable" || value === "nightly") { return value; @@ -899,6 +912,7 @@ export class Config { readonly srcDir: string; private readonly configFile: string; private readonly providersConfigStore: ProvidersConfigStore; + private readonly configWriteLeases: FileLeaseManager; private readonly emitter = new EventEmitter(); /** * Legacy variant grouping is hidden from the current runtime but retained here so unrelated @@ -918,6 +932,11 @@ export class Config { this.srcDir = sessionLocator.srcDir; this.configFile = path.join(this.rootDir, "config.json"); this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(this.rootDir); + // Config-write lock lease (config_write.lock): serializes every editConfig + // load->save and the telemetry field/marker transaction cross-process. + // Registry state that must outlive short-lived Config instances is + // module-scoped inside the lease manager. + this.configWriteLeases = new FileLeaseManager(this.rootDir); } private rememberLegacyTaskVariantWorkspace( @@ -1730,6 +1749,7 @@ export class Config { chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), muxGatewayEnabled, llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs), + telemetryEnabled: parseTelemetryEnabled(parsed.telemetryEnabled), heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt), heartbeatDefaultIntervalMs: parseOptionalHeartbeatIntervalMs( parsed.heartbeatDefaultIntervalMs @@ -1852,6 +1872,11 @@ export class Config { data.llmDebugLogs = llmDebugLogs; } + const telemetryEnabled = parseOptionalBoolean(config.telemetryEnabled); + if (telemetryEnabled !== undefined) { + data.telemetryEnabled = telemetryEnabled; + } + const heartbeatDefaultPrompt = parseOptionalNonEmptyString(config.heartbeatDefaultPrompt); if (heartbeatDefaultPrompt) { data.heartbeatDefaultPrompt = heartbeatDefaultPrompt; @@ -2418,7 +2443,7 @@ export class Config { * detail rather than a caller-initiated mutation. */ private enqueueConfigEdit(fn: (config: ProjectsConfig) => ProjectsConfig): Promise { - const run = this.editConfigQueue.then(async () => { + const body = async (): Promise => { const config = this.loadConfigOrDefault(); const newConfig = fn(config); // If that load failed, writing would replace the corrupt file with defaults. Only @@ -2426,9 +2451,9 @@ export class Config { // no confirmed backup, a concurrent replacement since the load, or an unreadable // file all reject the edit so callers do not treat the mutation as durable (unlike // saveConfig's log-and-swallow of unexpected I/O errors, this skip is deliberate). - // A missing file is safe to overwrite. This cannot fully close the cross-process - // race (that needs file locking, which editConfig has never had); it binds the - // approval to the current bytes and shrinks the window to the atomic write itself. + // A missing file is safe to overwrite. The write lock above closes the + // cross-process load->save race for cooperating xum processes; this check still + // binds the approval to the current bytes for writers that bypass the lock. const failureState = configLoadFailureStates.get(this.configFile); if (failureState) { const rejectEdit = (reason: string): never => { @@ -2462,6 +2487,35 @@ export class Config { // Backend-initiated config edits (for example gateway auth changes) use this signal // so frontend subscribers can refresh derived state without polling. this.notifyConfigChanged(); + }; + // In-section edits (the section's own nested writes, and any edit landing + // while a section holds the lock) bypass the queue: a queued in-section + // edit deadlocks — the section joins admitted edits while the queue turn + // it needs is held by a waiter that needs the section to finish. Atomic + // fresh loads still hold: the section owns the file lock, so no other + // writer's load->save is in flight, and bypassed edits serialize among + // themselves on sectionEditChain. + if (this.configWriteSectionActive) { + return this.runEditInsideActiveSection(body); + } + const run = this.editConfigQueue.then(async () => { + // Re-check under our queue turn: a section may have activated (run + // inside it) or begun acquiring (park until it owns the lock — running + // now would race the peer that still holds it, and queueing behind the + // section would cycle) while this edit waited. + for (;;) { + if (this.configWriteSectionActive) { + return this.runEditInsideActiveSection(body); + } + if (this.configWriteSectionPending) { + await this.configWriteSectionActivation; + continue; + } + // No section: take the file lock directly for this single write — + // never the chain (a queue-turn holder waiting on the chain can + // deadlock against a section awaiting its own nested edit). + return this.acquireConfigWriteLockAndRun(body); + } }); // Keep the queue alive when an edit fails; the failure still propagates to this caller. this.editConfigQueue = run.then( @@ -2480,6 +2534,370 @@ export class Config { return this.loadConfigOrDefault().llmDebugLogs === true; } + /** + * Settings → General telemetry opt-out; absent means enabled. + * + * Fail CLOSED: when the persisted state cannot be read, report disabled — + * corrupted or inaccessible state must not silently override an opt-out. A + * genuinely missing file is not an error (fresh install ⇒ enabled), but + * existsSync() masks traversal failures (EACCES on ~/.xum) as "missing", so + * stat explicitly to tell ENOENT apart from every other failure. Callers + * stay non-fatal either way. + */ + isTelemetryDisabledByConfig(): boolean { + // Downgrade backstop first: older builds' whitelist-based saveConfig drops + // the (to them unknown) telemetryEnabled field, so opt out -> downgrade -> + // change any setting -> upgrade would silently re-enable telemetry. The + // sidecar marker survives that round-trip (old builds never touch unknown + // files in the config dir, and they have no toggle that could legitimately + // re-enable), so its presence reads as disabled regardless of the field. + try { + fs.lstatSync(this.telemetryOptOutMarkerFile); + return true; + } catch (error) { + // Only a provably absent entry means "no marker". After a downgrade + // round-trip the marker can be the ONLY record of the opt-out, so a + // transient lookup failure (EIO, ESTALE, a dangling symlink's target) + // must fail closed rather than fall through to the now-absent field + // and resume telemetry. lstat keeps a dangling symlink reading as + // present. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return true; + } + } + try { + fs.statSync(this.configFile); + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } + try { + return this.loadConfigOrDefault({ throwOnError: true }).telemetryEnabled === false; + } catch { + return true; + } + } + + /** + * Sidecar marker for the telemetry opt-out (`telemetry_opt_out` next to + * config.json). config.json stays the primary, write-verified record; the + * marker only exists so the opt-out survives an upgrade↔downgrade round + * trip (see isTelemetryDisabledByConfig). + */ + private get telemetryOptOutMarkerFile(): string { + return path.join(this.rootDir, "telemetry_opt_out"); + } + + /** + * Persist the telemetry toggle: config.json field write, strict + * verification, and sidecar-marker sync as ONE guarded sequence. Any + * failure rolls the field back (best-effort) and throws, so success is only + * reported when both persisted records agree. The whole sequence holds a + * cross-process lock: concurrent toggles from peer processes sharing this + * Xum home would otherwise interleave the field write and the marker sync + * into a divergent final state (config says enabled, marker says disabled). + */ + async setTelemetryEnabledPersisted(enabled: boolean): Promise { + await this.runExclusiveConfigSection(async () => { + // Tolerant read of the prior state so failures below can restore it; on + // an unreadable config this reads as the default (enabled), matching + // what a rollback could re-persist anyway. + const previousDisabled = this.loadConfigOrDefault().telemetryEnabled === false; + const rollBackTelemetryField = async (): Promise => { + try { + await this.editConfig((config) => { + if (previousDisabled) { + config.telemetryEnabled = false; + } else { + delete config.telemetryEnabled; + } + return config; + }); + } catch { + // Best-effort rollback: the thrown error already reports the toggle + // as not applied, and any residual mismatch reads fail-closed + // (disabled), which is the privacy-safe direction. + } + }; + + await this.editConfig((config) => { + // Persist the choice EXPLICITLY in both directions (no sparsify-on- + // enable): a crash between this verified write and the marker sync + // must leave a field the startup reconciliation can repair FROM — an + // absent field with a stale marker is indistinguishable from the + // downgrade-survivor state and would restart opted out despite a + // successful re-enable. Absent still means enabled-by-default for + // configs that never touched the toggle. + config.telemetryEnabled = enabled; + return config; + }); + + // saveConfig swallows write errors (a full disk still resolves), but a + // privacy opt-out must not report success while the persisted state says + // "enabled" — the choice would silently un-apply on next launch. Re-read + // the disk STRICTLY and fail loudly, before touching the live client. + // isTelemetryDisabledByConfig() is deliberately not used here: its + // fail-closed read (unreadable ⇒ disabled) is right for enablement + // checks but would let a failed write + failed read masquerade as a + // confirmed opt-out. + let persistedDisabled: boolean; + try { + persistedDisabled = + this.loadConfigOrDefault({ throwOnError: true }).telemetryEnabled === false; + } catch { + // The atomic write may have LANDED before this read failed: without a + // rollback, a persisted opt-out with no marker would survive as a + // field a downgrade save then silently drops. + await rollBackTelemetryField(); + throw new Error( + "Could not verify the telemetry preference was persisted to config.json; the setting was not changed." + ); + } + if (persistedDisabled !== !enabled) { + await rollBackTelemetryField(); + throw new Error( + "Failed to persist the telemetry preference to config.json; the setting was not changed." + ); + } + + // Sync the downgrade-surviving sidecar marker only after the config + // write verified: the marker is a backstop, never the primary record. + // The two records must agree before we report success — a lost marker + // breaks the downgrade guarantee for an opt-out, and a stale marker + // overrides an explicit re-enable. + try { + this.setTelemetryOptOutMarker(!enabled); + } catch { + await rollBackTelemetryField(); + throw new Error( + "Could not update the telemetry opt-out marker file; the setting was not changed." + ); + } + // The nested field edit already notified, but that event fired BEFORE + // the marker sync — a peer client reading marker-aware getConfig() on it + // could still see the old effective state (stale marker). Emit again now + // that the full field/marker transaction is complete. + this.notifyConfigChanged(); + }); + } + + /** + * Cross-process config write lock, built on the same withDirLock protocol + * (and 45s/60s policy) as the providers coder-refresh lock. Every + * enqueueConfigEdit load->save holds it so a peer xum process's + * whole-config save built from a stale snapshot cannot silently revert a + * field another process just wrote; setTelemetryEnabledPersisted + * (runExclusiveConfigSection) holds it across its whole + * field/verification/marker sequence so the two records cannot interleave + * into divergence. + * + * In-process coordination has exactly two roles, and mixing them is what + * deadlocks (a queue-turn holder must never wait on a section, because the + * section may be waiting on the queue): + * + * - SECTIONS (runExclusiveConfigSection) never pass through anything. They + * are totally ordered by `configWriteChain`, acquire the file lock, and + * only then activate. They hold no editConfigQueue turn, so waiting on + * the chain and the file lock is cycle-free. + * - EDITS (enqueueConfigEdit) are leaves. While a section HOLDS the lock, + * an edit — the section's own nested writes and any unrelated edit — + * bypasses the queue entirely and runs inside the section, serialized on + * `sectionEditChain` (concurrent bypassed edits must not interleave their + * read-modify-writes) and tracked so the section joins it before + * releasing. While a section is still ACQUIRING, an edit's queue turn + * parks on the activation promise (running would race the peer that + * still owns the lock; queueing behind the section's own writes would + * cycle). With no section, an edit takes the file lock directly for its + * single write — never the chain. + * + * Acquisition failures on permanent filesystem errors (read-only home, + * EACCES, an uninspectable lock) degrade to an unlocked run: the advisory + * lock must not mask the write's own, more precise failure. Timeouts + * (genuine contention) propagate. + */ + private configWriteSectionActive = false; + private configWriteSectionPending = false; + private configWriteSectionActivation: Promise = Promise.resolve(); + private resolveConfigWriteSectionActivation: (() => void) | null = null; + private configWriteChain: Promise = Promise.resolve(); + private sectionEditChain: Promise = Promise.resolve(); + private readonly configWritePassThroughTasks = new Set>(); + + /** Run an edit body inside the active section: serialized and joined. */ + private runEditInsideActiveSection(body: () => Promise): Promise { + const run = this.sectionEditChain.then(body); + this.sectionEditChain = run.then( + () => undefined, + () => undefined + ); + const tracked: Promise = run.then( + () => undefined, + () => undefined + ); + this.configWritePassThroughTasks.add(tracked); + void tracked.then(() => { + this.configWritePassThroughTasks.delete(tracked); + }); + return run; + } + + /** + * Startup reconciliation for a crash that split the telemetry records — the + * process dying between the verified field write and the marker sync in + * setTelemetryEnabledPersisted. The marker follows an EXPLICIT field: + * telemetryEnabled: false recreates a missing marker (this also durably + * codifies a hand-edited opt-out), and an explicit true removes a stale one + * (a hand-declared re-enable). An ABSENT field with a marker present is + * left alone: that state is exactly the downgrade round-trip the marker + * exists to survive, a crash-mid-enable is indistinguishable from it, and + * failing closed (disabled) is the privacy-safe direction — the next + * explicit toggle repairs it. Best-effort by contract: startup + * initialization must never crash the app. + */ + async reconcileTelemetryOptOutMarker(): Promise { + try { + // Short acquisition: this runs on the startup critical path, and a peer + // legitimately holding the lock must not stall app initialization for + // the full 45s budget — telemetry safely stays fail-closed and the next + // explicit toggle (or restart) reconciles. + await this.acquireConfigWriteLockAndRun( + () => { + const field = this.loadConfigOrDefault().telemetryEnabled; + if (field === false) { + this.setTelemetryOptOutMarker(true); + } else if (field === true) { + this.setTelemetryOptOutMarker(false); + } + return Promise.resolve(); + }, + { acquireTimeoutMs: 2_000 } + ); + } catch { + // Best-effort: contention or an unwritable home leaves the records as + // they were; the next explicit toggle runs the full verified + // transaction. + } + } + + /** + * Public wrapper for writers OUTSIDE this instance's edit machinery (the + * mux_config_write tool's document writer) so their read/compare/write + * sequences serialize against every editConfig and telemetry transaction + * via the same config_write.lock — a preflight check without the lock is + * TOCTOU: a toggle can complete between the writer's read and its + * whole-document save, which would then restore the stale field. + */ + async withConfigDocumentWriteLock(fn: () => Promise): Promise { + return this.acquireConfigWriteLockAndRun(fn); + } + + /** + * Acquire the file lock and run fn. Failure handling distinguishes three + * cases: fn's own errors and acquisition timeouts propagate; a coded + * failure with NO lock present degrades to an unlocked run (the + * environment refused lock CREATION — read-only home — so no peer can hold + * one either, and the write's own failure is the more precise signal); a + * coded failure with the lock PRESENT rejects — ownership cannot be + * proven (EIO/ESTALE/EACCES inspecting a peer's lock), and writing + * unlocked could overlap the holder, losing updates or splitting the + * telemetry field/marker transaction. + */ + private async acquireConfigWriteLockAndRun( + fn: () => Promise, + options?: { acquireTimeoutMs?: number } + ): Promise { + const lockDir = path.join(this.rootDir, "config_write.lock"); + let started = false; + const runStarted = async (): Promise => { + started = true; + return fn(); + }; + try { + return await this.configWriteLeases.withConfigWriteLock(runStarted, options); + } catch (error) { + if (started) { + // fn itself threw inside an acquired hold; not a lock failure. + throw error; + } + const code = (error as NodeJS.ErrnoException).code; + if (code == null) { + // Acquisition timeout (withDirLock's Error carries no code): genuine + // contention must surface, not silently run unlocked. + throw error; + } + let lockPresent = false; + try { + fs.lstatSync(lockDir); + lockPresent = true; + } catch (statError) { + // A non-ENOENT stat is itself ambiguous — fail safe (treat as held). + lockPresent = (statError as NodeJS.ErrnoException).code !== "ENOENT"; + } + if (lockPresent) { + throw new Error( + `Could not verify the config write lock at ${lockDir} (${code}); refusing to write while another process may hold it.` + ); + } + return runStarted(); + } + } + + /** Chain-ordered, lock-holding section; see the coordination doc above. */ + private runExclusiveConfigSection(fn: () => Promise): Promise { + const run = this.configWriteChain.then(async () => { + this.configWriteSectionPending = true; + this.configWriteSectionActivation = new Promise((resolve) => { + this.resolveConfigWriteSectionActivation = resolve; + }); + try { + return await this.acquireConfigWriteLockAndRun(async () => { + this.configWriteSectionActive = true; + this.resolveConfigWriteSectionActivation?.(); + this.resolveConfigWriteSectionActivation = null; + try { + return await fn(); + } finally { + // Join edits admitted during this section BEFORE releasing (more + // may be admitted while joining — loop until drained), so their + // atomic saves cannot outlive the lock. + while (this.configWritePassThroughTasks.size > 0) { + await Promise.all([...this.configWritePassThroughTasks]); + } + this.configWriteSectionActive = false; + } + }); + } finally { + this.configWriteSectionPending = false; + // Release any parked edits even when acquisition failed. + this.resolveConfigWriteSectionActivation?.(); + this.resolveConfigWriteSectionActivation = null; + } + }); + this.configWriteChain = run.then( + () => undefined, + () => undefined + ); + return run; + } + + /** + * Throws on filesystem failure: the two persisted records must agree before + * the caller reports success — a lost marker silently breaks the downgrade + * guarantee for an opt-out, and a stale marker overrides an explicit + * re-enable. setTelemetryEnabledPersisted rolls the config field back when + * this throws. + */ + setTelemetryOptOutMarker(disabled: boolean): void { + if (disabled) { + fs.writeFileSync( + this.telemetryOptOutMarkerFile, + "Usage telemetry is disabled while this file exists (Settings → General).\n", + "utf-8" + ); + } else { + fs.rmSync(this.telemetryOptOutMarkerFile, { force: true }); + } + } + async setUpdateChannel(channel: UpdateChannel): Promise { await this.editConfig((config) => { config.updateChannel = channel; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index d7fc3b410f..cb93da9ed1 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-unsafe-argument, @typescript-eslint/require-await, local/no-sync-fs-methods */ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { createRouterClient } from "@orpc/server"; import * as fs from "fs"; import * as os from "os"; @@ -216,19 +216,37 @@ describe("router agent skill routes", () => { describe("router config transcript mutation", () => { let tempDir: string; let config: Config; + let setConfigEnabledMock: ReturnType Promise>>; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-router-test-")); config = new Config(tempDir); + setConfigEnabledMock = mock((_enabled: boolean) => Promise.resolve()); }); afterEach(() => { + // The write-failure test locks the dir; restore perms so cleanup succeeds. + try { + fs.chmodSync(tempDir, 0o700); + } catch { + // Already removed or never locked. + } fs.rmSync(tempDir, { recursive: true, force: true }); }); function createContext(): ORPCContext { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Only Config is used by this route. - return { config } as ORPCContext; + // These config-route tests touch Config, TaskService, and (via getConfig / + // updateTelemetryEnabled) TelemetryService; stub the rest of the container. + return { + config, + taskService: { + maybeStartQueuedTasks: () => Promise.resolve(undefined), + }, + telemetryService: { + isDisabledByEnv: () => false, + setConfigEnabled: setConfigEnabledMock, + }, + } as unknown as ORPCContext; } test("persists the full-width chat transcript config flag", async () => { @@ -243,4 +261,136 @@ describe("router config transcript mutation", () => { expect((await client.config.getConfig()).chatTranscriptFullWidth).toBe(false); expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + + // chmod-based error injection is meaningless where permission bits don't + // bind: root bypasses them (common in containerized CI) and Windows ACLs + // ignore POSIX modes entirely. + const permissionBitsEnforced = + process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() !== 0; + + test("updateTelemetryEnabled persists explicitly in both directions and applies the toggle", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.updateTelemetryEnabled({ enabled: false }); + + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(setConfigEnabledMock).toHaveBeenLastCalledWith(false); + // The downgrade-surviving sidecar marker tracks the opt-out. + expect(fs.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(true); + + await client.config.updateTelemetryEnabled({ enabled: true }); + + // Re-enabling stores an EXPLICIT true (not a sparse delete): a crash + // before the marker removal must leave a field the startup + // reconciliation can repair from, or the app restarts opted out despite + // a successful re-enable. + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(true); + expect(setConfigEnabledMock).toHaveBeenLastCalledWith(true); + expect(fs.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + }); + + test("updateTelemetryEnabled rolls the config field back when verification cannot read it", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // The atomic write can LAND before a transient read failure hits the + // strict verification. Reporting failure while leaving the field persisted + // (with no marker) would strand an opt-out that the next downgrade save + // silently drops — the route must restore the prior state instead. + const original = config.loadConfigOrDefault.bind(config); + let threwOnce = false; + const loadSpy = spyOn(config, "loadConfigOrDefault").mockImplementation(((options?: { + throwOnError?: boolean; + }) => { + if (options?.throwOnError && !threwOnce) { + threwOnce = true; + throw new Error("transient read failure"); + } + return original(options); + }) as typeof config.loadConfigOrDefault); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow( + /verify the telemetry preference/ + ); + } finally { + loadSpy.mockRestore(); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(fs.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + }); + + test("updateTelemetryEnabled rolls the config field back when the marker sync fails", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // Both persisted records must agree before the RPC reports success: a + // verified config write with a lost marker would silently break the + // downgrade guarantee, so the route must restore the prior field state + // and reject. + const markerSpy = spyOn(config, "setTelemetryOptOutMarker").mockImplementationOnce(() => { + throw new Error("disk full"); + }); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow( + /opt-out marker/ + ); + } finally { + markerSpy.mockRestore(); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(fs.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + }); + + test.skipIf(!permissionBitsEnforced)( + "updateTelemetryEnabled fails loudly when the config write does not land", + async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // saveConfig writes atomically (temp file + rename in the config dir), so a + // read-only dir makes the write fail. saveConfig swallows that error; the + // route must detect it anyway rather than report success for a privacy + // setting that will silently revert on next launch. + fs.chmodSync(tempDir, 0o500); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow( + /persist the telemetry preference/ + ); + } finally { + fs.chmodSync(tempDir, 0o700); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + } + ); + + test.skipIf(!permissionBitsEnforced)( + "updateTelemetryEnabled fails when persistence cannot be verified", + async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // Materialize config.json, then make it unreadable AND the dir unwritable: + // the disable write is swallowed and the verification read fails. A read + // failure must fail the RPC — it must not masquerade as a confirmed + // opt-out (the fail-closed enablement read would report disabled here). + // The exact rejection depends on which guard fires first (Config's + // corrupt-config backup protection can reject the write before the + // route's verification read); either way the RPC must reject. + await client.config.updateChatTranscriptFullWidth({ enabled: true }); + const configFile = path.join(tempDir, "config.json"); + fs.chmodSync(configFile, 0o000); + fs.chmodSync(tempDir, 0o500); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow(); + } finally { + fs.chmodSync(tempDir, 0o700); + fs.chmodSync(configFile, 0o600); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + } + ); }); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 27dc287b80..49b5c38a0d 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -233,7 +233,16 @@ export const router = (authToken?: string) => { getConfig: t .input(schemas.config.getConfig.input) .output(schemas.config.getConfig.output) - .handler(({ context }) => context.config.getClientConfig()), + .handler(({ context }) => ({ + ...context.config.getClientConfig(), + // Marker-aware effective state: after a downgrade round-trip dropped + // the config field, the sidecar marker still holds the opt-out — the + // UI must mirror what capture() enforces. The env hard-off rides + // along so the switch can render as disabled (Config cannot reach + // the telemetry service; the route composes the two). + telemetryEnabled: !context.config.isTelemetryDisabledByConfig(), + telemetryDisabledByEnv: context.telemetryService.isDisabledByEnv(), + })), onConfigChanged: t .input(schemas.config.onConfigChanged.input) .output(schemas.config.onConfigChanged.output) @@ -301,6 +310,19 @@ export const router = (authToken?: string) => { .input(schemas.config.updateLlmDebugLogs.input) .output(schemas.config.updateLlmDebugLogs.output) .handler(({ context, input }) => context.config.updateLlmDebugLogs(input.enabled)), + updateTelemetryEnabled: t + .input(schemas.config.updateTelemetryEnabled.input) + .output(schemas.config.updateTelemetryEnabled.output) + .handler(async ({ context, input }) => { + // Field write, strict verification, marker sync, and failure + // rollbacks live in Config behind a cross-process lock so the two + // persisted records (telemetryEnabled + the sidecar marker) can + // never diverge under concurrent toggles from peer processes. + await context.config.setTelemetryEnabledPersisted(input.enabled); + // Apply immediately: disabling shuts the client down mid-session, + // enabling re-runs the full enablement check (env vars still win). + await context.telemetryService.setConfigEnabled(input.enabled); + }), updateHeartbeatDefaultPrompt: t .input(schemas.config.updateHeartbeatDefaultPrompt.input) .output(schemas.config.updateHeartbeatDefaultPrompt.output) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 6c87cd129b..cea66559c4 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7646,13 +7646,15 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Disabling telemetry", "", - "To disable telemetry, set `XUM_DISABLE_TELEMETRY` before starting the app:", + "Toggle **Usage Telemetry** off in **Settings → General**. The change applies immediately (no restart) and persists as `telemetryEnabled: false` in the active Xum home's `config.json` — `~/.xum` by default, or the directory `XUM_ROOT` / an existing legacy `~/.mux` install points at. Opting out also drops a `telemetry_opt_out` marker file next to `config.json`, so the choice survives running an older Xum build whose settings writer doesn't know the field; toggling telemetry back on removes it. Builds that predate this toggle only honor the environment variable — if you opt out and plan to keep running such a build, also set `XUM_DISABLE_TELEMETRY=1`; the marker restores your choice for current builds once you upgrade again.", + "", + "Alternatively, set `XUM_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored):", "", "```bash", "XUM_DISABLE_TELEMETRY=1 xum", "```", "", - "This disables telemetry collection at the backend level.", + "The environment variable is a hard override: when set to `1`, telemetry stays off regardless of the Settings toggle, and the toggle renders disabled with a note saying so. Both switches disable collection at the backend level.", "", "## Source code", "", diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 77cd9b1445..82d959f8e2 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -167,7 +167,9 @@ export class ServiceContainer { // Cross-cutting services: created first so they can be passed to core // services via constructor params (no setter injection needed). this.policyService = new PolicyService(config); - this.telemetryService = new TelemetryService(config.rootDir); + this.telemetryService = new TelemetryService(config.rootDir, () => + config.isTelemetryDisabledByConfig() + ); this.experimentsService = new ExperimentsService({ telemetryService: this.telemetryService, xumHome: config.rootDir, @@ -587,7 +589,12 @@ export class ServiceContainer { await recordStep("extensionMetadata.initialize", () => this.extensionMetadata.initialize()); // Initialize telemetry service - await recordStep("telemetryService.initialize", () => this.telemetryService.initialize()); + await recordStep("telemetryService.initialize", async () => { + // Repair a crash-split telemetry preference (field written, marker sync + // lost) before the enablement gates read either record. + await this.config.reconcileTelemetryOptOutMarker(); + await this.telemetryService.initialize(); + }); // Initialize policy service (startup gating) await recordStep("policyService.initialize", () => this.policyService.initialize()); diff --git a/src/node/services/telemetryService.test.ts b/src/node/services/telemetryService.test.ts index e4d3bf0bdd..af5fe8e117 100644 --- a/src/node/services/telemetryService.test.ts +++ b/src/node/services/telemetryService.test.ts @@ -1,16 +1,55 @@ import { describe, expect, test } from "bun:test"; -import { shouldEnableTelemetry, type TelemetryEnablementContext } from "./telemetryService"; +import { + shouldEnableTelemetry, + TelemetryService, + type TelemetryEnablementContext, +} from "./telemetryService"; function createContext(overrides: Partial): TelemetryEnablementContext { return { env: overrides.env ?? {}, isElectron: overrides.isElectron ?? false, isPackaged: overrides.isPackaged ?? null, + disabledByConfig: overrides.disabledByConfig, }; } describe("TelemetryService enablement", () => { + test("setConfigEnabled applies the persisted truth, not the caller's stale intent", async () => { + // Concurrent toggles can reorder persist vs apply across RPCs; each queued + // apply must re-read the persisted state. Here the persisted state says + // ENABLED while a stale disable applies: the live client must survive. + let disabled = false; + const service = new TelemetryService(undefined, () => disabled); + (service as unknown as { client: unknown }).client = {}; + + await service.setConfigEnabled(false); + + expect((service as unknown as { client: unknown }).client).not.toBeNull(); + expect(service.isEnabled()).toBe(true); + + // And a genuine persisted disable still tears the client down. + disabled = true; + await service.setConfigEnabled(false); + expect((service as unknown as { client: unknown }).client).toBeNull(); + }); + + test("isEnabled reflects the live config gate, not just the client", () => { + let disabled = false; + const service = new TelemetryService(undefined, () => disabled); + // Simulate an initialized client (unit envs gate real initialization); + // capture() already refuses per event when the config gate flips, and + // status surfaces must agree with it. + (service as unknown as { client: unknown }).client = {}; + expect(service.isEnabled()).toBe(true); + + // A peer process opt-out through the shared config must read as disabled + // even while this process still holds the client. + disabled = true; + expect(service.isEnabled()).toBe(false); + }); + test("disables telemetry when explicitly disabled", () => { const enabled = shouldEnableTelemetry( createContext({ @@ -108,6 +147,32 @@ describe("TelemetryService enablement", () => { expect(enabled).toBe(true); }); + test("disables telemetry when the config opt-out is set", () => { + const enabled = shouldEnableTelemetry( + createContext({ + env: {}, + isElectron: true, + isPackaged: true, + disabledByConfig: true, + }) + ); + + expect(enabled).toBe(false); + }); + + test("the env var hard-off wins even when config says enabled", () => { + const enabled = shouldEnableTelemetry( + createContext({ + env: { MUX_DISABLE_TELEMETRY: "1" }, + isElectron: true, + isPackaged: true, + disabledByConfig: false, + }) + ); + + expect(enabled).toBe(false); + }); + test("enables telemetry in NODE_ENV=development by default", () => { // Telemetry is now enabled by default in dev mode const enabled = shouldEnableTelemetry( @@ -132,6 +197,17 @@ describe("TelemetryService enablement", () => { expect(enabled).toBe(true); }); + test("isExplicitlyDisabled reflects the config opt-out like the env var", () => { + // Features gated on explicit opt-out (e.g. link sharing) must treat the + // Settings toggle the same as MUX_DISABLE_TELEMETRY=1. + let disabled = false; + const service = new TelemetryService(undefined, () => disabled); + + expect(service.isExplicitlyDisabled()).toBe(false); + disabled = true; + expect(service.isExplicitlyDisabled()).toBe(true); + }); + test("dev opt-in does not bypass test env disable", () => { const enabled = shouldEnableTelemetry( createContext({ diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index 50b7d284e0..031abf1878 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -88,6 +88,8 @@ export interface TelemetryEnablementContext { env: NodeJS.ProcessEnv; isElectron: boolean; isPackaged: boolean | null; + /** User opt-out persisted in config.json (Settings → General). */ + disabledByConfig?: boolean; } export function shouldEnableTelemetry(context: TelemetryEnablementContext): boolean { @@ -96,6 +98,12 @@ export function shouldEnableTelemetry(context: TelemetryEnablementContext): bool return false; } + // User opt-out via config.json (telemetryEnabled: false). The env var and + // config switch are both hard-off; absence of both means enabled. + if (context.disabledByConfig === true) { + return false; + } + // Otherwise, telemetry is enabled (including dev mode) return true; } @@ -134,23 +142,50 @@ export class TelemetryService { private distinctId: string | null = null; private featureFlagVariants: Record = {}; private readonly xumHome: string; + private readonly isDisabledByConfig?: () => boolean; + private initInFlight: Promise | null = null; + private configApplyChain: Promise = Promise.resolve(); + // Set once by shutdown() at final app teardown and never cleared: the lazy + // capture()-path and initializeOnce()'s post-await re-check must refuse to + // install a client during or after teardown. Runtime opt-outs + // (setConfigEnabled(false)) deliberately do NOT set this — a peer process + // re-enabling the shared config must be able to lazily re-init this one, + // and the per-event config gate keeps capture() off in the meantime. + private terminalShutdown = false; + /** Rate limit for capture()'s lazy cross-process re-enable initialization. */ + private static readonly LAZY_INIT_RETRY_MS = 30_000; + private lastLazyInitAttemptMs = 0; /** - * Check if telemetry is enabled. - * Returns true only after initialize() completes and telemetry was not disabled. + * Check if telemetry is effectively enabled. + * A live client alone is not the truth: a peer process (or a manual shared + * config edit) can opt out while this process still holds an initialized + * client — capture() already gates per event, and status surfaces must + * agree with it. The env gate needs no re-check here: the environment is + * fixed for the process lifetime, and an env-disabled process never + * creates a client in the first place. */ isEnabled(): boolean { - return this.client !== null; + return this.client !== null && this.isDisabledByConfig?.() !== true; } /** - * Check if telemetry was explicitly disabled by the user via XUM_DISABLE_TELEMETRY=1. - * This is different from isEnabled() which also returns false in dev mode. - * Used to gate features like link sharing that should only be hidden when - * the user explicitly opts out of xum services. + * Check if telemetry was explicitly disabled by the user — either via + * XUM_DISABLE_TELEMETRY=1 or the Settings → General opt-out. This is + * different from isEnabled() which also returns false in test/CI contexts. + * Consumers gating on explicit opt-out must treat both switches the same; + * the docs present them as equivalent. */ isExplicitlyDisabled(): boolean { - return resolveXumEnvironmentValue("DISABLE_TELEMETRY", process.env) === "1"; + return ( + resolveXumEnvironmentValue("DISABLE_TELEMETRY", process.env) === "1" || + this.isDisabledByConfig?.() === true + ); + } + + /** The environment gate alone (env var, CI, tests) — surfaced to the UI so the Settings toggle can render as hard-disabled. */ + isDisabledByEnv(): boolean { + return isTelemetryDisabledByEnv(process.env); } /** @@ -180,15 +215,71 @@ export class TelemetryService { this.featureFlagVariants[key] = variant; } - constructor(xumHome?: string) { + constructor(xumHome?: string, isDisabledByConfig?: () => boolean) { this.xumHome = xumHome ?? getXumHome(); + this.isDisabledByConfig = isDisabledByConfig; + } + + /** + * Apply the Settings → General telemetry toggle at runtime: disabling shuts + * the PostHog client down (capture() no-ops on a null client), enabling + * re-runs initialize(), which re-checks every enablement gate. + * + * Applies are serialized across ALL callers: the desktop Settings pane and + * API-server clients drive the same router in one process with no shared + * frontend chain, and an unserialized shutdown/initialize interleaving can + * resurrect a capturing client after an opt-out, kill telemetry while the + * switch shows on, or orphan an unflushed client. + */ + async setConfigEnabled(enabled: boolean): Promise { + const next = this.configApplyChain.then(() => { + // Concurrent toggles can reorder persistence vs application across + // RPCs: A persists false and pauses, B persists AND applies true, then + // A applies its stale false — config says enabled while the client is + // down. Re-read the persisted truth at APPLY time so queued applies + // converge on the last persisted state instead of replaying their + // caller's intent. Without a config reader (bare constructions) the + // caller's value is the only truth available. + const effectiveEnabled = + this.isDisabledByConfig != null ? !this.isDisabledByConfig() : enabled; + if (effectiveEnabled) { + return this.initialize(); + } + // Runtime opt-out, not the terminal latch: tear the client down but + // leave lazy re-init armed, so a later re-enable — from this process or + // a peer writing the shared config — can bring telemetry back without a + // restart. While the config stays disabled, capture()'s per-event gate + // keeps events off regardless. + return this.teardownClient(); + }); + // Keep the chain usable after a failed apply. + this.configApplyChain = next.then( + () => undefined, + () => undefined + ); + return next; } /** * Initialize the PostHog client. * Should be called once on app startup. + * + * Re-entrancy-safe: the null-client guard and the client assignment are + * separated by awaits, so two concurrent initializes would otherwise both + * pass the guard and orphan a live client. */ async initialize(): Promise { + if (this.initInFlight) { + return this.initInFlight; + } + const run = this.initializeOnce().finally(() => { + this.initInFlight = null; + }); + this.initInFlight = run; + return run; + } + + private async initializeOnce(): Promise { if (this.client) { return; } @@ -202,14 +293,23 @@ export class TelemetryService { const isElectron = typeof process.versions.electron === "string"; const isPackaged = await getElectronIsPackaged(isElectron); + const disabledByConfig = this.isDisabledByConfig?.() === true; - if (!shouldEnableTelemetry({ env, isElectron, isPackaged })) { + if (!shouldEnableTelemetry({ env, isElectron, isPackaged, disabledByConfig })) { return; } // Load or generate distinct ID this.distinctId = await this.loadOrCreateDistinctId(); + // Terminal teardown may have started while the awaits above ran — the + // startup initialize() does not ride configApplyChain, so shutdown()'s + // queued teardown can complete before we get here. Installing the client + // now would leave a live PostHog past the final flush. + if (this.terminalShutdown) { + return; + } + this.client = new PostHog(DEFAULT_POSTHOG_KEY, { host: DEFAULT_POSTHOG_HOST, // Avoid geo-IP enrichment (we don't need coarse location for xum telemetry) @@ -269,7 +369,45 @@ export class TelemetryService { * Events are silently ignored when disabled. */ capture(payload: TelemetryEventPayload): void { - if (isTelemetryDisabledByEnv(process.env) || !this.client || !this.distinctId) { + // The config opt-out is re-checked per event, not just at initialize(): + // a second mux process sharing ~/.mux/config.json (mux server alongside + // the desktop app) must stop capturing when the user opts out in the + // other process. Event volume is low (discrete user actions), so the + // config read is acceptable here for a privacy control. + if (isTelemetryDisabledByEnv(process.env) || this.isDisabledByConfig?.() === true) { + return; + } + + if (!this.client || !this.distinctId) { + // Cross-process re-enable: this process may have started while the + // shared config said opted-out (client never created) and another + // process has since re-enabled. Kick a lazy, serialized initialize — + // rate-limited because every enablement gate (dev mode, packaging) + // still applies and may legitimately keep the client null. The current + // event is dropped; the process converges for subsequent ones. + const now = Date.now(); + if (now - this.lastLazyInitAttemptMs > TelemetryService.LAZY_INIT_RETRY_MS) { + this.lastLazyInitAttemptMs = now; + // Serialized with toggle applies, and latched off once shutdown + // begins: an unserialized initialize() here could install a fresh + // client while shutdown() is still awaiting the PostHog flush, + // leaving telemetry live after teardown. The queued task re-checks + // every gate when it actually runs. + this.configApplyChain = this.configApplyChain + .then(async () => { + if (this.terminalShutdown || this.client != null) { + return; + } + if (isTelemetryDisabledByEnv(process.env) || this.isDisabledByConfig?.() === true) { + return; + } + await this.initialize(); + }) + .then( + () => undefined, + () => undefined + ); + } return; } @@ -288,19 +426,44 @@ export class TelemetryService { /** * Shutdown telemetry and flush any pending events. - * Should be called on app close. + * Should be called on app close — this is the terminal teardown, distinct + * from the runtime opt-out (setConfigEnabled(false)): it latches lazy + * re-init off permanently. */ async shutdown(): Promise { - if (!this.client) { + // Latch first (synchronously): any lazy re-init task that runs from this + // instant on refuses at its terminalShutdown re-check, and initializeOnce + // re-checks after its awaits. + this.terminalShutdown = true; + // Ride the apply chain so an in-flight initialize() — a lazy task that + // passed the latch check and is awaiting the Electron import or + // telemetry-ID I/O — settles BEFORE the flush. A direct teardown here + // could observe a null client, return, and leak the client that task + // installs moments later; queued behind it, the teardown disposes + // whatever state it left. + const next = this.configApplyChain.then(() => this.teardownClient()); + this.configApplyChain = next.then( + () => undefined, + () => undefined + ); + return next; + } + + /** Null the client immediately (capture() no-ops), then flush it. */ + private async teardownClient(): Promise { + // Null BEFORE flushing: capture() must no-op the instant a teardown + // begins, and a concurrent initialize() must never observe the stale + // client and skip re-initialization. + const client = this.client; + this.client = null; + if (!client) { return; } try { - await this.client.shutdown(); + await client.shutdown(); } catch { // Silently ignore shutdown errors } - - this.client = null; } } diff --git a/src/node/services/tools/shared/configReadWrite.ts b/src/node/services/tools/shared/configReadWrite.ts index 58dedc13c8..07c6284cd6 100644 --- a/src/node/services/tools/shared/configReadWrite.ts +++ b/src/node/services/tools/shared/configReadWrite.ts @@ -5,6 +5,8 @@ import * as jsonc from "jsonc-parser"; import type { z } from "zod"; import writeFileAtomic from "write-file-atomic"; +import { Config } from "@/node/config"; + import { CONFIG_FILE_REGISTRY, type ConfigDocumentFor, @@ -90,30 +92,85 @@ async function assertWritableConfigTarget(filePath: string, fileKey: ConfigFileK } } +/** + * Serialize a config-document read/mutate/write sequence against every + * editConfig and the telemetry field/marker transaction via the shared + * cross-process config_write.lock. Callers holding this lock pass + * `configWriteLockHeld: true` to writeConfigDocument — the dir lock is not + * re-entrant, so a nested acquire would wait out its own hold. + */ +export async function withConfigDocumentWriteLock( + xumHomeDir: string, + fn: () => Promise +): Promise { + return new Config(xumHomeDir).withConfigDocumentWriteLock(fn); +} + export async function writeConfigDocument( xumHomeDir: string, fileKey: TKey, - document: unknown + document: unknown, + options?: { configWriteLockHeld?: boolean } ): Promise> { const entry = CONFIG_FILE_REGISTRY[fileKey]; const filePath = getConfigDocumentPath(xumHomeDir, fileKey); const validatedDocument = parseAndValidateDocument(fileKey, entry.schema, document, filePath); - const serialized = JSON.stringify(validatedDocument, null, 2); - await fs.mkdir(xumHomeDir, { recursive: true }); - await assertWritableConfigTarget(filePath, fileKey); + const performWrite = async (): Promise> => { + // The telemetry opt-out is a two-record transaction (the config field + // plus the downgrade-surviving telemetry_opt_out marker) that + // Config.setTelemetryEnabledPersisted runs behind the cross-process + // config write lock. This generic document writer has no marker sync, so + // a field change here could report success while the marker — and the + // live collector — disagree. Refuse the change and point at the real + // control. The compare runs under the lock below, so a toggle cannot + // complete between this read and the whole-document save. + if (fileKey === "config") { + const incoming = (validatedDocument as { telemetryEnabled?: unknown }).telemetryEnabled; + let current: unknown; + try { + current = ( + JSON.parse(await fs.readFile(filePath, "utf-8")) as { telemetryEnabled?: unknown } + ).telemetryEnabled; + } catch { + current = undefined; + } + if (!Object.is(incoming, current)) { + throw new Error( + 'Refusing to change "telemetryEnabled" through the generic config writer: the telemetry preference is a two-record transaction (config field + opt-out marker). Use the Settings → General toggle (config.updateTelemetryEnabled) instead.' + ); + } + } - if (entry.fileKind === "jsonc") { - writeFileAtomic.sync(filePath, `${PROVIDERS_JSONC_COMMENT_HEADER}${serialized}`, { - encoding: "utf-8", - mode: 0o600, - }); + const serialized = JSON.stringify(validatedDocument, null, 2); + await fs.mkdir(xumHomeDir, { recursive: true }); + await assertWritableConfigTarget(filePath, fileKey); + + if (entry.fileKind === "jsonc") { + writeFileAtomic.sync(filePath, `${PROVIDERS_JSONC_COMMENT_HEADER}${serialized}`, { + encoding: "utf-8", + mode: 0o600, + }); + + return validatedDocument; + } + + await writeFileAtomic(filePath, serialized, "utf-8"); return validatedDocument; + }; + + if (fileKey === "config" && options?.configWriteLockHeld !== true) { + // Serialize against every editConfig and telemetry transaction via the + // same cross-process lock, so this writer's compare/write cannot + // interleave with them and restore a stale whole-document snapshot + // (TOCTOU: without the lock, a toggle can complete between the compare + // above and the atomic save). Callers that already hold the lock (the + // mux_config_write tool, which also needs its SOURCE read under it) skip + // the nested acquire. + return withConfigDocumentWriteLock(xumHomeDir, performWrite); } - - await writeFileAtomic(filePath, serialized, "utf-8"); - return validatedDocument; + return performWrite(); } function parseAndValidateDocument( diff --git a/src/node/services/tools/xum_config_write.test.ts b/src/node/services/tools/xum_config_write.test.ts index d8b2b21ff4..ad3fdc470a 100644 --- a/src/node/services/tools/xum_config_write.test.ts +++ b/src/node/services/tools/xum_config_write.test.ts @@ -166,6 +166,50 @@ describe("mux_config_write", () => { }); }); + it("refuses to change telemetryEnabled through the generic writer", async () => { + using xumHome = new TestTempDir("mux-config-write"); + + const tool = await createWriteTool(xumHome.path, GLOBAL_WORKSPACE_ID); + // The telemetry preference is a two-record transaction (config field + + // opt-out marker) owned by Config.setTelemetryEnabledPersisted; a direct + // document write would split the records (no marker sync, no lock). + const result = (await tool.execute!( + { + file: "config", + operations: [{ op: "set", path: ["telemetryEnabled"], value: false }], + confirm: true, + }, + mockToolCallOptions + )) as XumConfigWriteResult; + + expect(result.success).toBe(false); + if (!result.success) { + expect(String(result.error)).toContain("telemetryEnabled"); + } + // Nothing persisted: neither the field nor a stray marker. + let written: { telemetryEnabled?: unknown } = {}; + try { + written = JSON.parse(await fs.readFile(path.join(xumHome.path, "config.json"), "utf-8")) as { + telemetryEnabled?: unknown; + }; + } catch { + // Missing file is equally "not persisted". + } + expect(written.telemetryEnabled).toBeUndefined(); + + // Unrelated fields still write while the field merely rides along + // UNCHANGED (absent -> absent). + const unrelated = (await tool.execute!( + { + file: "config", + operations: [{ op: "set", path: ["defaultModel"], value: "anthropic:claude-opus-5" }], + confirm: true, + }, + mockToolCallOptions + )) as XumConfigWriteResult; + expect(unrelated.success).toBe(true); + }); + it("preserves unknown nested fields when mutating unrelated key", async () => { using xumHome = new TestTempDir("mux-config-write"); diff --git a/src/node/services/tools/xum_config_write.ts b/src/node/services/tools/xum_config_write.ts index ad7e4af3e5..1441416e10 100644 --- a/src/node/services/tools/xum_config_write.ts +++ b/src/node/services/tools/xum_config_write.ts @@ -9,6 +9,7 @@ import { applyMutations } from "@/node/services/tools/shared/configMutationEngin import { REDACTED_SECRET_VALUE } from "@/node/services/tools/shared/configRedaction"; import { readConfigDocumentUnvalidated, + withConfigDocumentWriteLock, writeConfigDocument, } from "@/node/services/tools/shared/configReadWrite"; @@ -49,14 +50,33 @@ export const createXumConfigWriteTool: ToolFactory = (config: ToolConfiguration) } const xumHome = config.xumScope!.xumHome; - const currentDocument = await readConfigDocumentUnvalidated(xumHome, args.file); const registryEntry = CONFIG_FILE_REGISTRY[args.file]; - const mutationResult = applyMutations( - currentDocument, - args.operations, - registryEntry.schema, - { rootContainer: registryEntry.rootContainer } - ); + // The whole read->mutate->write runs under the cross-process config + // write lock for config.json: with only the final write locked, an + // editConfig landing between the source read and the save would be + // overwritten by this stale whole-document snapshot. + const runMutation = async () => { + const currentDocument = await readConfigDocumentUnvalidated(xumHome, args.file); + const mutationResult = applyMutations( + currentDocument, + args.operations, + registryEntry.schema, + { rootContainer: registryEntry.rootContainer } + ); + + if (!mutationResult.success) { + return mutationResult; + } + + await writeConfigDocument(xumHome, args.file, mutationResult.document, { + configWriteLockHeld: args.file === "config", + }); + return mutationResult; + }; + const mutationResult = + args.file === "config" + ? await withConfigDocumentWriteLock(xumHome, runMutation) + : await runMutation(); if (!mutationResult.success) { return { @@ -71,8 +91,6 @@ export const createXumConfigWriteTool: ToolFactory = (config: ToolConfiguration) }; } - await writeConfigDocument(xumHome, args.file, mutationResult.document); - // Notify services that config has changed (triggers hot-reload for providers) config.onConfigChanged?.();