patchwork-os 0.2.0-beta.5.canary.13 → 0.2.0-beta.5.canary.131

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +1 -1
  2. package/dist/analyticsPrefs.d.ts +3 -0
  3. package/dist/analyticsPrefs.js +7 -0
  4. package/dist/analyticsPrefs.js.map +1 -1
  5. package/dist/approvalHttp.js +15 -0
  6. package/dist/approvalHttp.js.map +1 -1
  7. package/dist/approvalQueue.d.ts +26 -0
  8. package/dist/approvalQueue.js +17 -0
  9. package/dist/approvalQueue.js.map +1 -1
  10. package/dist/bridge.js +16 -0
  11. package/dist/bridge.js.map +1 -1
  12. package/dist/commands/recipeInstall.js +5 -1
  13. package/dist/commands/recipeInstall.js.map +1 -1
  14. package/dist/commands/tools.d.ts +20 -1
  15. package/dist/commands/tools.js +112 -3
  16. package/dist/commands/tools.js.map +1 -1
  17. package/dist/haltPushDispatch.d.ts +33 -0
  18. package/dist/haltPushDispatch.js +103 -0
  19. package/dist/haltPushDispatch.js.map +1 -0
  20. package/dist/inboxRoutes.d.ts +22 -0
  21. package/dist/inboxRoutes.js +61 -1
  22. package/dist/inboxRoutes.js.map +1 -1
  23. package/dist/index.js +8 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/recipes/chainedRunner.js +51 -2
  26. package/dist/recipes/chainedRunner.js.map +1 -1
  27. package/dist/recipes/yamlRunner.d.ts +23 -1
  28. package/dist/recipes/yamlRunner.js +482 -260
  29. package/dist/recipes/yamlRunner.js.map +1 -1
  30. package/dist/recipesHttp.d.ts +2 -0
  31. package/dist/recipesHttp.js +20 -0
  32. package/dist/recipesHttp.js.map +1 -1
  33. package/dist/runLog.d.ts +22 -0
  34. package/dist/runLog.js +12 -1
  35. package/dist/runLog.js.map +1 -1
  36. package/dist/server.d.ts +14 -0
  37. package/dist/server.js +211 -11
  38. package/dist/server.js.map +1 -1
  39. package/dist/tools/batchLsp.d.ts +3 -0
  40. package/dist/tools/cancelClaudeTask.d.ts +1 -0
  41. package/dist/tools/clipboard.d.ts +2 -0
  42. package/dist/tools/closeTabs.d.ts +1 -0
  43. package/dist/tools/codeLens.d.ts +1 -0
  44. package/dist/tools/createIssueFromAIComment.d.ts +1 -0
  45. package/dist/tools/ctxSaveTrace.d.ts +1 -0
  46. package/dist/tools/debug.d.ts +4 -0
  47. package/dist/tools/decorations.d.ts +2 -0
  48. package/dist/tools/documentLinks.d.ts +1 -0
  49. package/dist/tools/editText.d.ts +1 -0
  50. package/dist/tools/enrichCommit.d.ts +1 -0
  51. package/dist/tools/explainDiagnostic.d.ts +1 -0
  52. package/dist/tools/explainSymbol.d.ts +1 -0
  53. package/dist/tools/fileOperations.d.ts +3 -0
  54. package/dist/tools/fileWatcher.d.ts +2 -0
  55. package/dist/tools/findFiles.d.ts +1 -0
  56. package/dist/tools/fixAllLintErrors.d.ts +1 -0
  57. package/dist/tools/foldingRanges.d.ts +1 -0
  58. package/dist/tools/formatDocument.d.ts +1 -0
  59. package/dist/tools/generateTests.d.ts +1 -0
  60. package/dist/tools/getAIComments.d.ts +1 -0
  61. package/dist/tools/getBufferContent.d.ts +1 -0
  62. package/dist/tools/getChangeImpact.d.ts +1 -0
  63. package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
  64. package/dist/tools/getCodeCoverage.d.ts +1 -0
  65. package/dist/tools/getCommitsForIssue.d.ts +1 -0
  66. package/dist/tools/getDebugState.d.ts +1 -0
  67. package/dist/tools/getDocumentSymbols.d.ts +1 -0
  68. package/dist/tools/getGitHotspots.d.ts +1 -0
  69. package/dist/tools/getImportedSignatures.d.ts +1 -0
  70. package/dist/tools/getPRTemplate.d.ts +1 -0
  71. package/dist/tools/getSymbolHistory.d.ts +1 -0
  72. package/dist/tools/getTypeSignature.d.ts +1 -0
  73. package/dist/tools/getWorkspaceSettings.d.ts +1 -0
  74. package/dist/tools/gitWrite.d.ts +11 -0
  75. package/dist/tools/github/actions.d.ts +2 -0
  76. package/dist/tools/github/composite.d.ts +3 -0
  77. package/dist/tools/github/issues.d.ts +4 -0
  78. package/dist/tools/github/pr.d.ts +7 -0
  79. package/dist/tools/handoffNote.d.ts +1 -0
  80. package/dist/tools/hoverAtCursor.d.ts +1 -0
  81. package/dist/tools/httpClient.d.ts +2 -0
  82. package/dist/tools/inlayHints.d.ts +1 -0
  83. package/dist/tools/launchQuickTask.d.ts +1 -0
  84. package/dist/tools/listClaudeTasks.d.ts +1 -0
  85. package/dist/tools/listTerminals.d.ts +1 -0
  86. package/dist/tools/lsp.d.ts +15 -0
  87. package/dist/tools/navigateToSymbolByName.d.ts +1 -0
  88. package/dist/tools/openDiff.d.ts +1 -0
  89. package/dist/tools/openFile.d.ts +1 -0
  90. package/dist/tools/organizeImports.d.ts +1 -0
  91. package/dist/tools/planPersistence.d.ts +3 -0
  92. package/dist/tools/previewEdit.d.ts +1 -0
  93. package/dist/tools/refactorAnalyze.d.ts +1 -0
  94. package/dist/tools/refactorPreview.d.ts +1 -0
  95. package/dist/tools/replaceBlock.d.ts +1 -0
  96. package/dist/tools/resumeClaudeTask.d.ts +1 -0
  97. package/dist/tools/runClaudeTask.d.ts +1 -0
  98. package/dist/tools/screenshot.d.ts +1 -0
  99. package/dist/tools/searchAndReplace.d.ts +1 -0
  100. package/dist/tools/searchWorkspace.d.ts +1 -0
  101. package/dist/tools/selectionRanges.d.ts +1 -0
  102. package/dist/tools/semanticTokens.d.ts +1 -0
  103. package/dist/tools/signatureHelp.d.ts +1 -0
  104. package/dist/tools/terminal.d.ts +6 -0
  105. package/dist/tools/testTraceToSource.d.ts +1 -0
  106. package/dist/tools/transaction.d.ts +4 -0
  107. package/dist/tools/typeHierarchy.d.ts +1 -0
  108. package/dist/tools/utils.d.ts +18 -0
  109. package/dist/tools/utils.js +28 -6
  110. package/dist/tools/utils.js.map +1 -1
  111. package/dist/tools/vscodeCommands.d.ts +2 -0
  112. package/dist/tools/vscodeTasks.d.ts +2 -0
  113. package/dist/tools/workspaceSettings.d.ts +1 -0
  114. package/dist/wireHaltPushDispatch.d.ts +38 -0
  115. package/dist/wireHaltPushDispatch.js +71 -0
  116. package/dist/wireHaltPushDispatch.js.map +1 -0
  117. package/package.json +1 -1
package/dist/server.d.ts CHANGED
@@ -35,9 +35,23 @@ export declare class Server extends EventEmitter<ServerEvents> {
35
35
  private oauthServer;
36
36
  private oauthIssuerUrl;
37
37
  private sseSubscriberCount;
38
+ /**
39
+ * Registered SSE subscribers on `/stream`, oldest-first by insertion
40
+ * order. Used as a defense-in-depth backstop: if a subscriber's peer is
41
+ * a dead-but-not-erroring socket (e.g. an orphaned proxy connection),
42
+ * the ping-write cleanup can fail to fire and the subscriber leaks. To
43
+ * guarantee a leak can never *permanently* brick `/stream`, a new
44
+ * request arriving at the cap evicts the oldest subscriber instead of
45
+ * returning 503 — mirroring the HTTP-session eviction in ADR-0005.
46
+ * The real cure for the known leak is the dashboard proxy aborting its
47
+ * upstream fetch on client disconnect; this is the backstop.
48
+ */
49
+ private sseSubscribers;
38
50
  /** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
39
51
  private _explainRulesCache;
40
52
  private static readonly MAX_SSE_SUBSCRIBERS;
53
+ /** Number of currently-registered `/stream` SSE subscribers. Read-only. */
54
+ get sseSubscribers_count(): number;
41
55
  /** Set by bridge to provide health data */
42
56
  healthDataFn: (() => Record<string, unknown>) | null;
43
57
  /** Set by bridge to provide Prometheus metrics */
package/dist/server.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { EventEmitter } from "node:events";
3
+ import { unlinkSync } from "node:fs";
3
4
  import http from "node:http";
4
5
  import { WebSocket, WebSocketServer as WsServer } from "ws";
5
- import { getAnalyticsPrefsAll, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
6
+ import { clearAnalyticsConfig, getAnalyticsConfig } from "./analyticsConfig.js";
7
+ import { getAnalyticsPrefsAll, getAnalyticsPrefsPath, getAnalyticsSaltPath, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
6
8
  import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
7
9
  import { getApprovalQueue } from "./approvalQueue.js";
8
10
  import { saveBridgeConfigDriver } from "./config.js";
@@ -65,9 +67,25 @@ export class Server extends EventEmitter {
65
67
  oauthServer = null;
66
68
  oauthIssuerUrl = null;
67
69
  sseSubscriberCount = 0;
70
+ /**
71
+ * Registered SSE subscribers on `/stream`, oldest-first by insertion
72
+ * order. Used as a defense-in-depth backstop: if a subscriber's peer is
73
+ * a dead-but-not-erroring socket (e.g. an orphaned proxy connection),
74
+ * the ping-write cleanup can fail to fire and the subscriber leaks. To
75
+ * guarantee a leak can never *permanently* brick `/stream`, a new
76
+ * request arriving at the cap evicts the oldest subscriber instead of
77
+ * returning 503 — mirroring the HTTP-session eviction in ADR-0005.
78
+ * The real cure for the known leak is the dashboard proxy aborting its
79
+ * upstream fetch on client disconnect; this is the backstop.
80
+ */
81
+ sseSubscribers = new Set();
68
82
  /** Cache for CC permission rules (30s TTL) to avoid filesystem walks on each dashboard poll */
69
83
  _explainRulesCache = null;
70
84
  static MAX_SSE_SUBSCRIBERS = 20;
85
+ /** Number of currently-registered `/stream` SSE subscribers. Read-only. */
86
+ get sseSubscribers_count() {
87
+ return this.sseSubscriberCount;
88
+ }
71
89
  /** Set by bridge to provide health data */
72
90
  healthDataFn = null;
73
91
  /** Set by bridge to provide Prometheus metrics */
@@ -772,10 +790,18 @@ export class Server extends EventEmitter {
772
790
  return;
773
791
  }
774
792
  if (req.url === "/stream" && req.method === "GET") {
793
+ // Backstop (ADR-0005 pattern): at the cap, evict the oldest
794
+ // subscriber rather than returning 503 forever. A leaked
795
+ // subscriber whose socket is dead-but-not-erroring can otherwise
796
+ // permanently brick /stream. The dashboard proxy abort-on-disconnect
797
+ // fix is the real cure; this guarantees recovery either way.
798
+ // sseSubscriberCount and sseSubscribers.size stay in lockstep
799
+ // (both mutated together below + in cleanup()), so when the
800
+ // counter is at the cap there is always an evictable subscriber —
801
+ // eviction always frees the slot.
775
802
  if (this.sseSubscriberCount >= Server.MAX_SSE_SUBSCRIBERS) {
776
- res.writeHead(503, { "Content-Type": "application/json" });
777
- res.end(JSON.stringify({ error: "Too many SSE subscribers (max 20)" }));
778
- return;
803
+ const oldest = this.sseSubscribers.values().next().value;
804
+ oldest?.();
779
805
  }
780
806
  this.sseSubscriberCount++;
781
807
  // Disable socket timeout — SSE connections are long-lived by design
@@ -798,15 +824,24 @@ export class Server extends EventEmitter {
798
824
  let cleanedUp = false;
799
825
  let pingHandle = null;
800
826
  let unsub = () => { };
827
+ // Single teardown closure, also stored directly in the registry
828
+ // as this subscriber's eviction handler — it tears down state AND
829
+ // destroys the socket so the peer (and any orphaned proxy in
830
+ // between) observes the close. It removes this exact function
831
+ // from the Set, so no placeholder/no-op function is ever created.
801
832
  const cleanup = () => {
802
833
  if (cleanedUp)
803
834
  return;
804
835
  cleanedUp = true;
805
836
  this.sseSubscriberCount--;
837
+ this.sseSubscribers.delete(cleanup);
806
838
  if (pingHandle)
807
839
  clearInterval(pingHandle);
808
840
  unsub();
841
+ res.end();
842
+ res.socket?.destroy();
809
843
  };
844
+ this.sseSubscribers.add(cleanup);
810
845
  unsub =
811
846
  this.streamFn?.((kind, entry) => {
812
847
  try {
@@ -1159,6 +1194,21 @@ export class Server extends EventEmitter {
1159
1194
  return;
1160
1195
  }
1161
1196
  if (parsedUrl.pathname === "/settings" && req.method === "POST") {
1197
+ // Kill-switch gate: during an incident a settings mutation can
1198
+ // defeat the panic posture (e.g. switching driver to one with a
1199
+ // leaked API key, flipping approvalGate to "off"). The /settings
1200
+ // card on the dashboard sits directly above the kill-switch
1201
+ // toggle — users will reasonably read "writes blocked" as
1202
+ // covering both. Refuse with 423 Locked; the /kill-switch
1203
+ // endpoint itself is the only way out and is not gated.
1204
+ if (isWriteKillSwitchActive()) {
1205
+ res.writeHead(423, { "Content-Type": "application/json" });
1206
+ res.end(JSON.stringify({
1207
+ error: "kill_switch_blocked",
1208
+ reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
1209
+ }));
1210
+ return;
1211
+ }
1162
1212
  // 16 KB — settings POSTs are short-string fields (URLs, API keys,
1163
1213
  // gate level). 16 KB is generous; an authenticated attacker can't
1164
1214
  // stream gigabytes here.
@@ -1280,6 +1330,20 @@ export class Server extends EventEmitter {
1280
1330
  respond400("pushServiceUrl must be HTTPS");
1281
1331
  return;
1282
1332
  }
1333
+ // pushServiceToken — bearer for the push relay. Charset cap to
1334
+ // printable ASCII (no newlines / control chars) so it can't
1335
+ // header-inject when later interpolated into an outgoing
1336
+ // `Authorization` header. 512 chars is an order of magnitude
1337
+ // above any realistic relay token.
1338
+ const pushTokenTrimmed = body.pushServiceToken !== undefined
1339
+ ? body.pushServiceToken.trim()
1340
+ : undefined;
1341
+ if (pushTokenTrimmed !== undefined &&
1342
+ pushTokenTrimmed !== "" &&
1343
+ !/^[\x21-\x7E]{1,512}$/.test(pushTokenTrimmed)) {
1344
+ respond400("pushServiceToken must be 1-512 printable ASCII characters");
1345
+ return;
1346
+ }
1283
1347
  // pushServiceBaseUrl — bridge callback origin embedded in SW
1284
1348
  // approveUrl/rejectUrl. http:// or attacker host = approval token
1285
1349
  // exfiltration. Validate before persisting.
@@ -1387,8 +1451,8 @@ export class Server extends EventEmitter {
1387
1451
  if (pushUrlTrimmed !== undefined) {
1388
1452
  cfg.pushServiceUrl = pushUrlTrimmed || undefined;
1389
1453
  }
1390
- if (body.pushServiceToken !== undefined) {
1391
- cfg.pushServiceToken = body.pushServiceToken.trim() || undefined;
1454
+ if (pushTokenTrimmed !== undefined) {
1455
+ cfg.pushServiceToken = pushTokenTrimmed || undefined;
1392
1456
  }
1393
1457
  if (pushBaseTrimmed !== undefined) {
1394
1458
  cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
@@ -1407,7 +1471,19 @@ export class Server extends EventEmitter {
1407
1471
  saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1408
1472
  }
1409
1473
  catch (writeErr) {
1410
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1474
+ // Some Keychain / Secret-Service backends echo the
1475
+ // payload into the error message on certain failure
1476
+ // modes. Cap the logged message at 200 chars AND
1477
+ // strip anything that looks like the leading hex/
1478
+ // base64 of the just-attempted key so a misbehaving
1479
+ // backend can't leak it into bridge logs.
1480
+ const raw = writeErr instanceof Error
1481
+ ? writeErr.message
1482
+ : String(writeErr);
1483
+ const safe = raw
1484
+ .replaceAll(body.apiKey.key, "[redacted]")
1485
+ .slice(0, 200);
1486
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${safe}`);
1411
1487
  res.writeHead(500, { "Content-Type": "application/json" });
1412
1488
  res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1413
1489
  return;
@@ -1438,7 +1514,20 @@ export class Server extends EventEmitter {
1438
1514
  // PHASE 4 — live state. Only after every persistence step
1439
1515
  // succeeded; ensures live state never diverges from disk.
1440
1516
  if (gateRaw !== undefined) {
1517
+ const prevGate = this.approvalGate;
1441
1518
  this.approvalGate = gateRaw;
1519
+ // When the operator downgrades to "off", every pending
1520
+ // queue entry becomes moot — the originating tool dispatch
1521
+ // now bypasses, and a phone approver who hits "Approve"
1522
+ // five seconds later would get a 404. Cancel them
1523
+ // server-side so the dashboard /approvals list clears
1524
+ // and any pending phone notification reflects reality.
1525
+ if (gateRaw === "off" && prevGate !== "off") {
1526
+ const cancelled = getApprovalQueue().cancelAll();
1527
+ if (cancelled > 0) {
1528
+ this.logger.info(`[/settings] approvalGate → off; cancelled ${cancelled} pending entr${cancelled === 1 ? "y" : "ies"}`);
1529
+ }
1530
+ }
1442
1531
  }
1443
1532
  if (body.enableTimeOfDayAnomaly !== undefined) {
1444
1533
  this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
@@ -1449,8 +1538,8 @@ export class Server extends EventEmitter {
1449
1538
  if (pushUrlTrimmed !== undefined) {
1450
1539
  this.pushServiceUrl = pushUrlTrimmed || undefined;
1451
1540
  }
1452
- if (body.pushServiceToken !== undefined) {
1453
- this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1541
+ if (pushTokenTrimmed !== undefined) {
1542
+ this.pushServiceToken = pushTokenTrimmed || undefined;
1454
1543
  }
1455
1544
  if (pushBaseTrimmed !== undefined) {
1456
1545
  this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
@@ -1471,6 +1560,52 @@ export class Server extends EventEmitter {
1471
1560
  body.model !== undefined ||
1472
1561
  body.localEndpoint !== undefined ||
1473
1562
  body.localModel !== undefined;
1563
+ // Audit-log emission — forensic record of every config mutation
1564
+ // so an operator can answer "who changed what, when?" after an
1565
+ // incident. Bearer-token auth doesn't carry an actor identity
1566
+ // (no user JWT) so we attribute to "http" with the request IP.
1567
+ // Secrets are redacted to the shape `"***"` — value presence
1568
+ // is recorded, value content is never logged.
1569
+ if (this.activityLog) {
1570
+ const changes = {};
1571
+ if (hasWebhookUpdate)
1572
+ changes.webhookUrl = webhookRaw || "";
1573
+ if (gateRaw !== undefined)
1574
+ changes.approvalGate = gateRaw;
1575
+ if (body.enableTimeOfDayAnomaly !== undefined)
1576
+ changes.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1577
+ if (driverRaw !== undefined)
1578
+ changes.driver = driverRaw;
1579
+ if (body.model !== undefined)
1580
+ changes.model = body.model;
1581
+ if (body.localEndpoint !== undefined)
1582
+ changes.localEndpoint = body.localEndpoint;
1583
+ if (body.localModel !== undefined)
1584
+ changes.localModel = body.localModel;
1585
+ if (body.apiKey)
1586
+ changes.apiKey = { provider: body.apiKey.provider, key: "***" };
1587
+ if (pushUrlTrimmed !== undefined)
1588
+ changes.pushServiceUrl = pushUrlTrimmed || "";
1589
+ if (pushTokenTrimmed !== undefined)
1590
+ changes.pushServiceToken = pushTokenTrimmed ? "***" : "";
1591
+ if (pushBaseTrimmed !== undefined)
1592
+ changes.pushServiceBaseUrl = pushBaseTrimmed || "";
1593
+ if (ntfyTopicTrimmed !== undefined)
1594
+ changes.ntfyTopic = ntfyTopicTrimmed ? "***" : "";
1595
+ if (ntfyServerTrimmed !== undefined)
1596
+ changes.ntfyServer = ntfyServerTrimmed || "";
1597
+ const remoteAddr = req.headers["x-forwarded-for"]
1598
+ ?.split(",")[0]
1599
+ ?.trim() ||
1600
+ req.socket.remoteAddress ||
1601
+ "unknown";
1602
+ this.activityLog.recordEvent("settings.change", {
1603
+ actor: "http",
1604
+ ip: remoteAddr,
1605
+ fields: Object.keys(changes),
1606
+ changes,
1607
+ });
1608
+ }
1474
1609
  res.writeHead(200, { "Content-Type": "application/json" });
1475
1610
  res.end(JSON.stringify({ ok: true, restartRequired }));
1476
1611
  }
@@ -1613,8 +1748,10 @@ export class Server extends EventEmitter {
1613
1748
  }
1614
1749
  }
1615
1750
  // /telemetry-prefs — read/write per-flag telemetry preferences.
1616
- // GET → {crashReports, usageStats, localDiagnostics}
1617
- // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
1751
+ // GET → {crashReports, usageStats, localDiagnostics, endpoint, endpointSource}
1752
+ // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
1753
+ // DELETE → clears prefs file + analytics-config + install salt
1754
+ // (right-to-erasure affordance).
1618
1755
  if (parsedUrl.pathname === "/telemetry-prefs") {
1619
1756
  if (req.method === "GET") {
1620
1757
  const prefs = getTelemetryPrefs();
@@ -1623,11 +1760,74 @@ export class Server extends EventEmitter {
1623
1760
  if (all?.lastSentAt !== undefined) {
1624
1761
  response.lastSentAt = all.lastSentAt;
1625
1762
  }
1763
+ // Destination visibility — consent baseline. Caller can see
1764
+ // where their data would go and whether the destination came
1765
+ // from env (CI/headless) or the on-disk config file.
1766
+ const envEndpoint = process.env.PATCHWORK_ANALYTICS_ENDPOINT;
1767
+ const cfgEndpoint = getAnalyticsConfig().endpoint;
1768
+ const defaultEndpoint = "https://analytics.claude-ide-bridge.dev/v1/usage";
1769
+ if (envEndpoint) {
1770
+ response.endpoint = envEndpoint;
1771
+ response.endpointSource = "env";
1772
+ }
1773
+ else if (cfgEndpoint) {
1774
+ response.endpoint = cfgEndpoint;
1775
+ response.endpointSource = "config";
1776
+ }
1777
+ else {
1778
+ response.endpoint = defaultEndpoint;
1779
+ response.endpointSource = "default";
1780
+ }
1626
1781
  res.writeHead(200, { "Content-Type": "application/json" });
1627
1782
  res.end(JSON.stringify(response));
1628
1783
  return;
1629
1784
  }
1785
+ if (req.method === "DELETE") {
1786
+ // Right-to-erasure: drop prefs, analytics-config (endpoint /
1787
+ // shared secret), and the install salt. Next outbound send
1788
+ // (if any) will mint a fresh salt and treat this as a new
1789
+ // install. Kill-switch is honored here too — incident-mode
1790
+ // operators may still want to scrub local state, so allow
1791
+ // it; flip if the threat model changes.
1792
+ try {
1793
+ unlinkSync(getAnalyticsPrefsPath());
1794
+ }
1795
+ catch {
1796
+ /* missing is fine */
1797
+ }
1798
+ try {
1799
+ unlinkSync(getAnalyticsSaltPath());
1800
+ }
1801
+ catch {
1802
+ /* missing is fine */
1803
+ }
1804
+ clearAnalyticsConfig();
1805
+ if (this.activityLog) {
1806
+ this.activityLog.recordEvent("telemetry.reset", {
1807
+ actor: "http",
1808
+ ip: req.headers["x-forwarded-for"]
1809
+ ?.split(",")[0]
1810
+ ?.trim() ||
1811
+ req.socket.remoteAddress ||
1812
+ "unknown",
1813
+ });
1814
+ }
1815
+ res.writeHead(200, { "Content-Type": "application/json" });
1816
+ res.end(JSON.stringify({ ok: true }));
1817
+ return;
1818
+ }
1630
1819
  if (req.method === "POST") {
1820
+ // Kill-switch gate — telemetry prefs are config writes too.
1821
+ // GET stays open so operators can verify state during an
1822
+ // incident.
1823
+ if (isWriteKillSwitchActive()) {
1824
+ res.writeHead(423, { "Content-Type": "application/json" });
1825
+ res.end(JSON.stringify({
1826
+ error: "kill_switch_blocked",
1827
+ reason: "Telemetry pref writes are disabled while the write kill-switch is engaged.",
1828
+ }));
1829
+ return;
1830
+ }
1631
1831
  const TP_BODY_CAP = 1 * 1024;
1632
1832
  const parsed = await readJsonBody(req, TP_BODY_CAP);
1633
1833
  if (!parsed.ok) {