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.
- package/README.md +1 -1
- package/dist/analyticsPrefs.d.ts +3 -0
- package/dist/analyticsPrefs.js +7 -0
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/approvalHttp.js +15 -0
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +26 -0
- package/dist/approvalQueue.js +17 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/bridge.js +16 -0
- package/dist/bridge.js.map +1 -1
- package/dist/commands/recipeInstall.js +5 -1
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/tools.d.ts +20 -1
- package/dist/commands/tools.js +112 -3
- package/dist/commands/tools.js.map +1 -1
- package/dist/haltPushDispatch.d.ts +33 -0
- package/dist/haltPushDispatch.js +103 -0
- package/dist/haltPushDispatch.js.map +1 -0
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +61 -1
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/recipes/chainedRunner.js +51 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +23 -1
- package/dist/recipes/yamlRunner.js +482 -260
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +2 -0
- package/dist/recipesHttp.js +20 -0
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +22 -0
- package/dist/runLog.js +12 -1
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +14 -0
- package/dist/server.js +211 -11
- package/dist/server.js.map +1 -1
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/cancelClaudeTask.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +1 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/ctxSaveTrace.d.ts +1 -0
- package/dist/tools/debug.d.ts +4 -0
- package/dist/tools/decorations.d.ts +2 -0
- package/dist/tools/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDocumentSymbols.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/composite.d.ts +3 -0
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/handoffNote.d.ts +1 -0
- package/dist/tools/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +1 -0
- package/dist/tools/listClaudeTasks.d.ts +1 -0
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +15 -0
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/planPersistence.d.ts +3 -0
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorPreview.d.ts +1 -0
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/resumeClaudeTask.d.ts +1 -0
- package/dist/tools/runClaudeTask.d.ts +1 -0
- package/dist/tools/screenshot.d.ts +1 -0
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchWorkspace.d.ts +1 -0
- package/dist/tools/selectionRanges.d.ts +1 -0
- package/dist/tools/semanticTokens.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/transaction.d.ts +4 -0
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/utils.d.ts +18 -0
- package/dist/tools/utils.js +28 -6
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/vscodeCommands.d.ts +2 -0
- package/dist/tools/vscodeTasks.d.ts +2 -0
- package/dist/tools/workspaceSettings.d.ts +1 -0
- package/dist/wireHaltPushDispatch.d.ts +38 -0
- package/dist/wireHaltPushDispatch.js +71 -0
- package/dist/wireHaltPushDispatch.js.map +1 -0
- 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 {
|
|
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
|
-
|
|
777
|
-
|
|
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 (
|
|
1391
|
-
cfg.pushServiceToken =
|
|
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
|
-
|
|
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 (
|
|
1453
|
-
this.pushServiceToken =
|
|
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
|
|
1617
|
-
// POST
|
|
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) {
|