diff --git a/bun.lock b/bun.lock index fb9961f1486..051e0b0a13a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@coder/xum", @@ -1455,7 +1456,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -3599,7 +3600,7 @@ "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], @@ -3837,26 +3838,14 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "@jest/console/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@jest/console/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@jest/core/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "@jest/core/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/core/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - "@jest/environment/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - - "@jest/fake-timers/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - - "@jest/pattern/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - - "@jest/reporters/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@jest/reporters/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/reporters/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -3873,8 +3862,6 @@ "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "@jest/types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3975,16 +3962,18 @@ "@types/body-parser/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "@types/connect/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/cacheable-request/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/cors/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/connect/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@types/express-serve-static-core/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "@types/fs-extra/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/keyv/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@types/plist/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@types/responselike/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@types/send/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@types/serve-static/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], @@ -3995,10 +3984,6 @@ "@types/wait-on/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "@types/write-file-atomic/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - - "@types/ws/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "@types/yauzl/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -4031,8 +4016,6 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "bun-types/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "caching-transform/write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4069,6 +4052,8 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "electron/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4119,8 +4104,6 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -4161,8 +4144,6 @@ "jest-environment-node/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], - "jest-haste-map/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "jest-junit/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], @@ -4173,8 +4154,6 @@ "jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-mock/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "jest-process-manager/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-process-manager/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -4187,8 +4166,6 @@ "jest-runner/source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], - "jest-runtime/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "jest-runtime/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-runtime/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -4197,8 +4174,6 @@ "jest-snapshot/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "jest-util/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-util/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], @@ -4207,16 +4182,12 @@ "jest-watch-typeahead/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "jest-watcher/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "jest-watcher/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "jest-watcher/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-watcher/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], - "jest-worker/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], - "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -4385,28 +4356,16 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@jest/console/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@jest/console/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/console/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/core/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@jest/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "@jest/core/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/core/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/environment/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@jest/fake-timers/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@jest/pattern/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@jest/reporters/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@jest/reporters/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/reporters/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4429,8 +4388,6 @@ "@jest/transform/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@jest/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4481,36 +4438,14 @@ "@testing-library/jest-dom/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@types/asn1/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/body-parser/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/connect/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/cors/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/fs-extra/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/plist/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@types/keyv/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/send/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/serve-static/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@types/responselike/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@types/sshpk/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/wait-on/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/write-file-atomic/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@2.0.5", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ=="], @@ -4541,8 +4476,6 @@ "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "caching-transform/write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -4581,6 +4514,8 @@ "electron-publish/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "eslint/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -4601,14 +4536,10 @@ "global-prefix/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "htmlparser2/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "jest-circus/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-circus/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-circus/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4637,10 +4568,6 @@ "jest-each/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-environment-node/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "jest-haste-map/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-junit/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -4651,8 +4578,6 @@ "jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-mock/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-process-manager/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-process-manager/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4661,14 +4586,10 @@ "jest-resolve/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-runner/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-runner/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-runner/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-runtime/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-runtime/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-runtime/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4685,8 +4606,6 @@ "jest-snapshot/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-util/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4695,8 +4614,6 @@ "jest-validate/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "jest-watcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "jest-watcher/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "jest-watcher/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -4705,8 +4622,6 @@ "jest-watcher/string-length/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "jest-worker/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "nodemon/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index 03fc8dfc910..90dbecf4a76 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 991fc98023e..10b64b40188 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 { @@ -365,6 +403,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 25278225cdd..f207ba9910b 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -60,6 +60,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") { @@ -238,6 +239,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( @@ -246,12 +252,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 ); @@ -268,6 +313,7 @@ export function GeneralSection() { const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; + const telemetryEnabledNonce = ++telemetryEnabledLoadNonceRef.current; void api.config .getConfig() @@ -303,6 +349,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) { @@ -437,6 +488,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) { @@ -1014,6 +1206,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. + + )} +
+
+ +
+
+
+

Projects

diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 3f47b43dfda..46f0efc22c2 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -157,6 +157,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. */ @@ -410,6 +412,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl userPreferences: initialUserPreferences, taskSettings: initialTaskSettings, agentAiDefaults: initialAgentAiDefaults, + telemetryEnabled: initialTelemetryEnabled, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, @@ -655,6 +658,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; + let telemetryEnabled = initialTelemetryEnabled ?? true; const mockStats: ChatStats = { consumers: [], @@ -797,6 +801,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, + telemetryEnabled, + telemetryDisabledByEnv: false, }), saveConfig: (input: { taskSettings?: unknown; @@ -842,6 +848,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 45f23dab1a8..92701478ab7 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -94,6 +94,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 4a43ad8a4bd..478f9f5c7cc 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -230,6 +230,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; @@ -1973,6 +1980,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 3e908166648..e6cd591bbbc 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -154,6 +154,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 f108e524291..44a8e1de5f3 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2564,6 +2564,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, @@ -2652,6 +2657,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 0ade15e269f..f01c65fb885 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 00000000000..06c6ec0b6ff --- /dev/null +++ b/src/node/config.telemetryEnabled.test.ts @@ -0,0 +1,426 @@ +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; + +/** The rejection of a promise that must reject. */ +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + throw new Error("expected the promise to reject"); +} + +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("self-heals a directory-shaped marker so the toggle can re-enable telemetry", async () => { + const config = new Config(tempDir); + const markerPath = path.join(tempDir, "telemetry_opt_out"); + await fs.mkdir(markerPath); + + // Corrupted state reads fail-closed (disabled)... + expect(config.isTelemetryDisabledByConfig()).toBe(true); + // ...but an explicit re-enable must not fail forever on EISDIR (and roll + // its field back) while the directory keeps forcing the opt-out. + await config.setTelemetryEnabledPersisted(true); + expect(fsSync.existsSync(markerPath)).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + // Unknown content is quarantined, not destroyed. + const quarantined = (await fs.readdir(tempDir)).filter((name) => + name.startsWith("telemetry_opt_out.") + ); + expect(quarantined).toHaveLength(1); + + // Opting out over a directory-shaped marker writes a real marker file. + await fs.mkdir(markerPath); + await config.setTelemetryEnabledPersisted(false); + expect((await fs.lstat(markerPath)).isFile()).toBe(true); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it("quarantines a symlink-shaped marker instead of writing through it", async () => { + const config = new Config(tempDir); + const markerPath = path.join(tempDir, "telemetry_opt_out"); + const victim = path.join(tempDir, "victim.txt"); + await fs.writeFile(victim, "precious", "utf-8"); + await fs.symlink(victim, markerPath); + + // A link reads as an opt-out (fail closed) but is never a healthy marker: + // recreating the marker from an explicit false field must not follow it + // into the target. + expect(config.isTelemetryDisabledByConfig()).toBe(true); + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": false }`, "utf-8"); + await config.reconcileTelemetryOptOutMarker(); + expect(await fs.readFile(victim, "utf-8")).toBe("precious"); + expect((await fs.lstat(markerPath)).isSymbolicLink()).toBe(false); + expect((await fs.lstat(markerPath)).isFile()).toBe(true); + const quarantined = (await fs.readdir(tempDir)).filter((name) => + name.startsWith("telemetry_opt_out.malformed-") + ); + expect(quarantined).toHaveLength(1); + expect((await fs.lstat(path.join(tempDir, quarantined[0]))).isSymbolicLink()).toBe(true); + + // Re-enabling over a planted link removes the link, never the target. + await fs.rm(markerPath); + await fs.symlink(victim, markerPath); + await config.setTelemetryEnabledPersisted(true); + expect(await fs.readFile(victim, "utf-8")).toBe("precious"); + expect(fsSync.existsSync(markerPath)).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + }); + + it("never truncates a hard-linked marker in place", async () => { + // lstat reports a hard link as a plain file and O_NOFOLLOW cannot see it: + // writing the marker through the shared inode would overwrite the other + // name's content. The marker must be a fresh inode renamed over the path. + const config = new Config(tempDir); + const markerPath = path.join(tempDir, "telemetry_opt_out"); + const victim = path.join(tempDir, "victim.txt"); + await fs.writeFile(victim, "precious", "utf-8"); + await fs.link(victim, markerPath); + + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": false }`, "utf-8"); + await config.reconcileTelemetryOptOutMarker(); + + expect(await fs.readFile(victim, "utf-8")).toBe("precious"); + const [markerStat, victimStat] = await Promise.all([fs.stat(markerPath), fs.stat(victim)]); + expect(markerStat.ino).not.toBe(victimStat.ino); + expect(victimStat.nlink).toBe(1); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + // No temp file left behind. + expect((await fs.readdir(tempDir)).filter((name) => name.includes(".tmp-"))).toHaveLength(0); + }); + + it("keeps a marker-only opt-out when the replacement marker cannot be written", async () => { + // After a downgrade round trip, or with a malformed marker and no field, + // the marker is the only record of the opt-out. A toggle that quarantines + // it and then fails to write the replacement must roll back to DISABLED: + // snapshotting just the field reads "enabled", and that rollback would + // silently enable collection. + const config = new Config(tempDir); + const markerPath = path.join(tempDir, "telemetry_opt_out"); + await fs.mkdir(markerPath); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + + const withMarker = config as unknown as { + setTelemetryOptOutMarker: (disabled: boolean) => void; + quarantineMalformedTelemetryOptOutMarker: () => void; + }; + const markerSpy = spyOn(withMarker, "setTelemetryOptOutMarker").mockImplementation(() => { + // The real sync quarantines the malformed entry first, then fails. + withMarker.quarantineMalformedTelemetryOptOutMarker(); + throw new Error("EIO"); + }); + try { + await rejectionOf(config.setTelemetryEnabledPersisted(false)); + } finally { + markerSpy.mockRestore(); + } + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it("aborts the marker sync when the lock was displaced after the field write", async () => { + // On platforms without process birth identity a holder stalled past the + // lease can be reclaimed by a peer that completes a newer toggle. The + // stale holder must re-validate ownership before the marker mutation: + // recreating or removing the peer's marker would undo its toggle. + const config = new Config(tempDir); + const lockPath = path.join(tempDir, "locks", "project-registration.lock"); + const notifiable = config as unknown as { notifyConfigChanged: () => void }; + const originalNotify = notifiable.notifyConfigChanged.bind(config); + let displaceOnNextNotify = false; + // The nested field edit notifies after its save and before the marker + // sync: displace the lock in that window. + const notifySpy = spyOn(notifiable, "notifyConfigChanged").mockImplementation(() => { + if (displaceOnNextNotify && fsSync.existsSync(lockPath)) { + displaceOnNextNotify = false; + fsSync.writeFileSync(lockPath, "424242:peer-reclaimed", "utf-8"); + } + originalNotify(); + }); + try { + displaceOnNextNotify = true; + const rejection = await rejectionOf(config.setTelemetryEnabledPersisted(false)); + expect(rejection.message).toMatch(/lock/i); + } finally { + notifySpy.mockRestore(); + } + // The displaced holder never touched the marker. + expect(fsSync.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + }); + + it("skips the rollback's marker restore when the lock was displaced during the rollback", async () => { + // The rollback is a mutation under the lock like the forward path: its + // field edit asserts ownership inside editConfig, and the marker restore + // that follows must re-validate too — a holder displaced during the + // awaited edit must not recreate or remove a peer's newer marker. + const config = new Config(tempDir); + const lockPath = path.join(tempDir, "locks", "project-registration.lock"); + const withMarker = config as unknown as { + setTelemetryOptOutMarker: (disabled: boolean) => void; + }; + const notifiable = config as unknown as { notifyConfigChanged: () => void }; + const originalNotify = notifiable.notifyConfigChanged.bind(config); + let rollingBack = false; + // The forward marker sync fails once (after the verified field write), + // forcing the rollback; the rollback's own field edit notifies after its + // save — displace the lock in that window, before the marker restore. + const markerSpy = spyOn(withMarker, "setTelemetryOptOutMarker").mockImplementationOnce(() => { + rollingBack = true; + throw new Error("EIO"); + }); + const notifySpy = spyOn(notifiable, "notifyConfigChanged").mockImplementation(() => { + if (rollingBack && fsSync.existsSync(lockPath)) { + rollingBack = false; + fsSync.writeFileSync(lockPath, "424242:peer-reclaimed", "utf-8"); + } + originalNotify(); + }); + let rejection: Error; + try { + rejection = await rejectionOf(config.setTelemetryEnabledPersisted(false)); + } finally { + notifySpy.mockRestore(); + } + // mockRestore() also clears the call history, so read it first. + const markerCalls = markerSpy.mock.calls.length; + markerSpy.mockRestore(); + expect(rejection.message).toMatch(/opt-out marker/); + // The forward sync was attempted once; the displaced rollback never + // touched the marker. + expect(markerCalls).toBe(1); + expect(fsSync.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + }); + + it("leaves the marker alone when the startup reconciliation lock was displaced", async () => { + // Startup reconciliation mutates the marker from the explicit field. A hold + // displaced between the field read and that mutation (a peer completed a + // newer toggle meanwhile) must not create or remove the peer's marker. + const config = new Config(tempDir); + await fs.writeFile(path.join(tempDir, "config.json"), `{ "telemetryEnabled": false }`, "utf-8"); + const lockPath = path.join(tempDir, "locks", "project-registration.lock"); + const original = config.loadConfigOrDefault.bind(config); + const loadSpy = spyOn(config, "loadConfigOrDefault").mockImplementation(((options?: { + throwOnError?: boolean; + }) => { + const result = original(options); + // Displace the hold right after the field read, before the marker mutation. + if (fsSync.existsSync(lockPath)) { + fsSync.writeFileSync(lockPath, "424242:peer-reclaimed", "utf-8"); + } + return result; + }) as typeof config.loadConfigOrDefault); + try { + await config.reconcileTelemetryOptOutMarker(); + } finally { + loadSpy.mockRestore(); + } + expect(fsSync.existsSync(path.join(tempDir, "telemetry_opt_out"))).toBe(false); + }); + + it("a telemetry toggle does not deadlock against an in-flight unrelated edit", async () => { + const config = new Config(tempDir); + // An ordinary editConfig mid-save — holding its queue permit and its own + // hold of the registration file lock — while the toggle starts. The toggle + // must WAIT for the file lock, and once it owns it, its nested field edits + // must commit under that hold (never try to take the lock again and never + // park behind the toggle itself) — 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("toggles on a fresh home, creating the root and the lock directory first", async () => { + // First run: neither the home nor its locks/ directory exists yet. The + // transaction must create them and take the registration lock, not fail + // (or silently run unlocked) on ENOENT. + 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, "locks", "project-registration.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/index.ts b/src/node/config/index.ts index cdb4782cf08..4f65e7b077d 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -89,9 +89,11 @@ import { getContainerName as getDockerContainerName } from "@/node/runtime/Docke import { deriveProjectHierarchy } from "@/common/utils/subProjects"; import { type ProjectRegistrationLockHandle, + projectRegistrationLockFilePath, tryProjectRegistrationFileLock, withProjectRegistrationFileLock, } from "@/node/config/projectRegistrationLock"; +import { acquireProcessFileLock } from "@/node/utils/concurrency/fileLock"; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -286,6 +288,22 @@ 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; +} + +/** Startup budget for the telemetry marker reconciliation's registration-lock wait. */ +const TELEMETRY_RECONCILE_LOCK_TIMEOUT_MS = 2_000; + function parseUpdateChannel(value: unknown): UpdateChannel | undefined { if (value === "stable" || value === "nightly" || value === "npm") { return value; @@ -1818,6 +1836,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 @@ -1959,6 +1978,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; @@ -2800,6 +2824,306 @@ 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 runs under + * one hold of the project registration file lock — the cross-process lock + * every editConfig save already commits under — so concurrent toggles from + * peer processes sharing this Xum home cannot interleave the field write + * and the marker sync into a divergent final state (config says enabled, + * marker says disabled). The nested edits pass the hold explicitly: the + * lock is not reentrant, and an edit issued inside the window must commit + * under the window's hold instead of waiting for it to end. + */ + async setTelemetryEnabledPersisted(enabled: boolean): Promise { + await withProjectRegistrationFileLock(this.rootDir, async (lock) => { + const withinRegistrationLock = { withinRegistrationLock: lock }; + // Prior EFFECTIVE state so failures below can restore it — marker-aware: + // after a downgrade round trip, or with a malformed marker (a directory, + // a symlink) and no field, the marker is the only record of the opt-out. + // Snapshotting just the field would read "enabled", and once the marker + // sync has quarantined that entry, a failed toggle's rollback would + // delete the field too — a failed opt-out request silently enabling + // collection. The fail-closed read (unreadable ⇒ disabled) is the right + // bias for a rollback target. + const previousDisabled = this.isTelemetryDisabledByConfig(); + const rollBackTelemetryState = async (): Promise => { + try { + // Rollback writes are mutations under the lock like the forward + // ones: a displaced holder must not restore over a peer's newer + // toggle (editConfig asserts before its save; the marker needs the + // same check). + await lock.assertStillOwned(); + await this.editConfig((config) => { + if (previousDisabled) { + config.telemetryEnabled = false; + } else { + delete config.telemetryEnabled; + } + return config; + }, withinRegistrationLock); + // The awaited edit is a displacement window of its own: re-validate + // before this second mutation, or a stale rollback could recreate a + // marker over a peer's newer enable (or remove its downgrade backstop). + await lock.assertStillOwned(); + // Restore the downgrade backstop the marker sync may have + // quarantined or removed. + this.setTelemetryOptOutMarker(previousDisabled); + } 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; + }, withinRegistrationLock); + + // 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 rollBackTelemetryState(); + throw new Error( + "Could not verify the telemetry preference was persisted to config.json; the setting was not changed." + ); + } + if (persistedDisabled !== !enabled) { + await rollBackTelemetryState(); + throw new Error( + "Failed to persist the telemetry preference to config.json; the setting was not changed." + ); + } + + // The marker mutation is irreversible work under the lock, like the + // field save (which asserts inside editConfig): on platforms without + // process birth identity a holder stalled past the lease can have been + // displaced by a peer that already completed a newer toggle, and + // recreating or removing the peer's marker would undo it. Re-validate + // ownership immediately before touching the marker. + try { + await lock.assertStillOwned(); + } catch { + await rollBackTelemetryState(); + throw new Error( + "Lost the configuration lock before the telemetry opt-out marker could be updated; re-open Settings to confirm the current value." + ); + } + // 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 rollBackTelemetryState(); + 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(); + }); + } + + /** + * 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 { + // Startup critical path: a bounded wait for the registration lock, not + // the full 60s budget a peer's restore window may legitimately hold it + // for. Not a single attempt either — the load-time migration persist + // takes the same lock in the background of this process, and a + // reconciliation that gave up on that brief hold would silently skip + // on most first launches. A timeout leaves the records as they are: + // telemetry safely stays fail-closed meanwhile, and the next explicit + // toggle (or restart) reconciles. + await using lock = await acquireProcessFileLock({ + lockPath: projectRegistrationLockFilePath(this.rootDir), + timeoutMs: TELEMETRY_RECONCILE_LOCK_TIMEOUT_MS, + label: "project registration lock", + }); + const field = this.loadConfigOrDefault().telemetryEnabled; + if (field === false || field === true) { + // The field read is a displacement window too: a holder frozen past + // the lease can be reclaimed by a peer that completes a newer toggle, + // and mutating the marker afterwards would undo it — a removed opt-out + // marker is lost for good once an old build's save drops the field. + await lock.assertStillOwned(); + this.setTelemetryOptOutMarker(field === false); + } + } catch { + // Best-effort: an unwritable home leaves the records as they were; the + // next explicit toggle runs the full verified transaction. + } + } + + /** + * 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 { + this.quarantineMalformedTelemetryOptOutMarker(); + if (disabled) { + // Create a NEW inode and rename it over the marker path. Whatever sits + // there is replaced, never truncated in place: a hard link to another + // file (which lstat reports as a plain file and O_NOFOLLOW cannot + // catch) or a symlink planted after the quarantine check can therefore + // never lead the marker text into someone else's file. O_EXCL and + // O_NOFOLLOW (where the platform has it) guard the temp path itself. + const tempPath = `${this.telemetryOptOutMarkerFile}.tmp-${process.pid}-${Date.now()}`; + const fd = fs.openSync( + tempPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + ((fs.constants.O_NOFOLLOW as number | undefined) ?? 0) + ); + try { + fs.writeSync( + fd, + "Usage telemetry is disabled while this file exists (Settings → General).\n" + ); + } finally { + fs.closeSync(fd); + } + try { + fs.renameSync(tempPath, this.telemetryOptOutMarkerFile); + } catch (error) { + fs.rmSync(tempPath, { force: true }); + throw error; + } + } else { + fs.rmSync(this.telemetryOptOutMarkerFile, { force: true }); + } + } + + /** + * Self-heal a marker path occupied by anything but a regular file. A + * directory (corrupted state, a stray mkdir) makes writeFileSync and rmSync + * fail with EISDIR, so every toggle would roll its field back while the + * entry keeps reading as an opt-out — the Settings control could never + * re-enable telemetry without manual filesystem repair. A symlink is worse: + * the marker write would follow it and truncate whatever the link points + * at. Rename the entry aside (never delete unknown content; renaming a link + * moves the link, not its target) so the marker write or removal proceeds + * on a path we own. + */ + private quarantineMalformedTelemetryOptOutMarker(): void { + let stats: fs.Stats; + try { + stats = fs.lstatSync(this.telemetryOptOutMarkerFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + if (stats.isFile()) { + return; + } + const quarantinePath = `${this.telemetryOptOutMarkerFile}.malformed-${Date.now()}`; + fs.renameSync(this.telemetryOptOutMarkerFile, quarantinePath); + log.warn("Quarantined malformed telemetry opt-out marker", { + markerPath: this.telemetryOptOutMarkerFile, + quarantinePath, + }); + } + 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 2f90ff92a62..ceced34ce25 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, ORPCError } from "@orpc/server"; import * as fs from "fs"; import * as os from "os"; @@ -217,19 +217,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 () => { @@ -292,4 +310,169 @@ describe("router config transcript mutation", () => { await call; expect(inFlightProcedureCount()).toBe(0); }); + + // 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 applies the runtime toggle even when the client aborts mid-persist", async () => { + // Persistence and the live application are one uninterruptible section: + // a client abort while the field/marker write is pending must not leave + // the records changed and the running client untouched. + let started!: () => void; + const persistStarted = new Promise((resolve) => (started = resolve)); + let finish!: () => void; + const persist = new Promise((resolve) => (finish = resolve)); + const persistSpy = spyOn(config, "setTelemetryEnabledPersisted").mockImplementation(() => { + started(); + return persist; + }); + try { + const client = createRouterClient(router(), { context: createContext() }); + const controller = new AbortController(); + const call = client.config + .updateTelemetryEnabled({ enabled: false }, { signal: controller.signal }) + .catch((error: unknown) => error); + await persistStarted; + controller.abort(); + finish(); + await call; + } finally { + persistSpy.mockRestore(); + } + expect(setConfigEnabledMock).toHaveBeenCalledWith(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. The registration + // lock lives in locks/ — pre-create it writable so the transaction gets + // past lock acquisition to the write this test is about. + fs.mkdirSync(path.join(tempDir, "locks"), { recursive: true }); + 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); + // Keep the registration lock acquirable (see the read-only-dir test above). + fs.mkdirSync(path.join(tempDir, "locks"), { recursive: true }); + 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 fc787bad648..0fb0d6cb101 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -282,7 +282,16 @@ export const router = (authToken?: string) => { .output(schemas.config.getConfig.output) .handler( handlerGen(function* ({ context }) { - return yield* Effect.sync(() => context.config.getClientConfig()); + return yield* Effect.sync(() => ({ + ...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(), + })); }) ), // Event-iterator subscription: stays on the plain handler until the Effect @@ -400,6 +409,26 @@ export const router = (authToken?: string) => { yield* atomicPromise(async () => context.config.updateLlmDebugLogs(input.enabled)); }) ), + updateTelemetryEnabled: t + .input(schemas.config.updateTelemetryEnabled.input) + .output(schemas.config.updateTelemetryEnabled.output) + .handler( + handlerGen(function* ({ 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. + // Persistence and the live application are ONE uninterruptible + // section: a client abort while the write is pending must not + // leave the records changed and the running client untouched. + yield* atomicPromise(async () => { + 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 15e0c9e57e7..18bfa895c46 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7834,13 +7834,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/di/layers/desktop.ts b/src/node/services/di/layers/desktop.ts index ed9a30b848f..65b333ab4d3 100644 --- a/src/node/services/di/layers/desktop.ts +++ b/src/node/services/di/layers/desktop.ts @@ -186,7 +186,11 @@ export const CrossCuttingLive: Layer.Layer = Layer.effectContext( Effect.map(ConfigTag, (config) => { const policyService = new PolicyService(config); - const telemetryService = new TelemetryService(config.rootDir); + // The Settings → General opt-out gates the collector at capture time (not + // only at initialize), so a toggle applies to the running process. + const telemetryService = new TelemetryService(config.rootDir, () => + config.isTelemetryDisabledByConfig() + ); const experimentsService = new ExperimentsService({ telemetryService, xumHome: config.rootDir, diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 797eb426515..46747bc0431 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -369,7 +369,15 @@ export class ServiceContainer { */ private readonly startupCoreSteps: readonly StartupStep[] = [ { name: "extensionMetadata.initialize", run: () => this.extensionMetadata.initialize() }, - { name: "telemetryService.initialize", run: () => this.telemetryService.initialize() }, + { + name: "telemetryService.initialize", + run: 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(); + }, + }, // Startup gating { name: "policyService.initialize", run: () => this.policyService.initialize() }, { name: "experimentsService.initialize", run: () => this.experimentsService.initialize() }, diff --git a/src/node/services/telemetryService.test.ts b/src/node/services/telemetryService.test.ts index e4d3bf0bdd9..af5fe8e1178 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 50b7d284e00..031abf18787 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/xum_config_write.test.ts b/src/node/services/tools/xum_config_write.test.ts index 5892990cf9a..76505a1dca9 100644 --- a/src/node/services/tools/xum_config_write.test.ts +++ b/src/node/services/tools/xum_config_write.test.ts @@ -169,6 +169,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 2d95a995ec6..da355032b70 100644 --- a/src/node/services/tools/xum_config_write.ts +++ b/src/node/services/tools/xum_config_write.ts @@ -78,6 +78,28 @@ export const createXumConfigWriteTool: ToolFactory = (config: ToolConfiguration) }; } + // The telemetry opt-out is a two-record transaction (the config field + // plus the downgrade-surviving telemetry_opt_out marker) that + // Config.setTelemetryEnabledPersisted runs under this same 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 registration hold, so a toggle cannot + // complete between the source read above and the save below. + if (args.file === "config") { + const before = (currentDocument as { telemetryEnabled?: unknown } | null) + ?.telemetryEnabled; + const after = (mutationResult.document as { telemetryEnabled?: unknown }) + .telemetryEnabled; + if (!Object.is(before, after)) { + return { + success: false, + error: + 'Refusing to change "telemetryEnabled" through the generic config writer: the telemetry preference is a two-record transaction (config field + opt-out marker). Ask the user to toggle Usage Telemetry in Settings → General instead.', + }; + } + } + await lock?.assertStillOwned(); await writeConfigDocument(xumHome, args.file, mutationResult.document); return {