patchwork-os 0.2.0-beta.5.canary.8 → 0.2.0-beta.5.canary.80
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 +9 -0
- package/dist/approvalQueue.js +17 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/bridge.js +9 -0
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/patchworkConfig.d.ts +18 -0
- package/dist/patchworkConfig.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 +11 -1
- package/dist/recipes/yamlRunner.js +94 -5
- 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/server.js +274 -16
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
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";
|
|
9
11
|
import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
|
|
10
12
|
import { timingSafeStringEqual } from "./crypto.js";
|
|
11
13
|
import { renderDashboardHtml } from "./dashboard.js";
|
|
14
|
+
import { isLoopbackOrPrivateEndpoint } from "./drivers/local/index.js";
|
|
12
15
|
import { EnvLockedFlagError, getEnvLockedValue, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, setFlag, } from "./featureFlags.js";
|
|
13
16
|
import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
|
|
14
17
|
import { respond500 } from "./httpErrorResponse.js";
|
|
@@ -1158,6 +1161,21 @@ export class Server extends EventEmitter {
|
|
|
1158
1161
|
return;
|
|
1159
1162
|
}
|
|
1160
1163
|
if (parsedUrl.pathname === "/settings" && req.method === "POST") {
|
|
1164
|
+
// Kill-switch gate: during an incident a settings mutation can
|
|
1165
|
+
// defeat the panic posture (e.g. switching driver to one with a
|
|
1166
|
+
// leaked API key, flipping approvalGate to "off"). The /settings
|
|
1167
|
+
// card on the dashboard sits directly above the kill-switch
|
|
1168
|
+
// toggle — users will reasonably read "writes blocked" as
|
|
1169
|
+
// covering both. Refuse with 423 Locked; the /kill-switch
|
|
1170
|
+
// endpoint itself is the only way out and is not gated.
|
|
1171
|
+
if (isWriteKillSwitchActive()) {
|
|
1172
|
+
res.writeHead(423, { "Content-Type": "application/json" });
|
|
1173
|
+
res.end(JSON.stringify({
|
|
1174
|
+
error: "kill_switch_blocked",
|
|
1175
|
+
reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
|
|
1176
|
+
}));
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1161
1179
|
// 16 KB — settings POSTs are short-string fields (URLs, API keys,
|
|
1162
1180
|
// gate level). 16 KB is generous; an authenticated attacker can't
|
|
1163
1181
|
// stream gigabytes here.
|
|
@@ -1279,6 +1297,20 @@ export class Server extends EventEmitter {
|
|
|
1279
1297
|
respond400("pushServiceUrl must be HTTPS");
|
|
1280
1298
|
return;
|
|
1281
1299
|
}
|
|
1300
|
+
// pushServiceToken — bearer for the push relay. Charset cap to
|
|
1301
|
+
// printable ASCII (no newlines / control chars) so it can't
|
|
1302
|
+
// header-inject when later interpolated into an outgoing
|
|
1303
|
+
// `Authorization` header. 512 chars is an order of magnitude
|
|
1304
|
+
// above any realistic relay token.
|
|
1305
|
+
const pushTokenTrimmed = body.pushServiceToken !== undefined
|
|
1306
|
+
? body.pushServiceToken.trim()
|
|
1307
|
+
: undefined;
|
|
1308
|
+
if (pushTokenTrimmed !== undefined &&
|
|
1309
|
+
pushTokenTrimmed !== "" &&
|
|
1310
|
+
!/^[\x21-\x7E]{1,512}$/.test(pushTokenTrimmed)) {
|
|
1311
|
+
respond400("pushServiceToken must be 1-512 printable ASCII characters");
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1282
1314
|
// pushServiceBaseUrl — bridge callback origin embedded in SW
|
|
1283
1315
|
// approveUrl/rejectUrl. http:// or attacker host = approval token
|
|
1284
1316
|
// exfiltration. Validate before persisting.
|
|
@@ -1309,6 +1341,41 @@ export class Server extends EventEmitter {
|
|
|
1309
1341
|
respond400("ntfyServer must be HTTPS");
|
|
1310
1342
|
return;
|
|
1311
1343
|
}
|
|
1344
|
+
// localEndpoint — used by LocalApiDriver as the inference base
|
|
1345
|
+
// URL. Must parse as http(s)://, be ≤2048 chars, and resolve to
|
|
1346
|
+
// a loopback or private address. Otherwise prompts + context
|
|
1347
|
+
// would stream to a public / metadata / file:// host. Same gate
|
|
1348
|
+
// the driver enforces at construction, raised to the HTTP
|
|
1349
|
+
// boundary so the bad value never reaches disk. Operator can
|
|
1350
|
+
// opt out via LOCAL_ENDPOINT_ALLOW_REMOTE=1 for audited
|
|
1351
|
+
// internal inference clusters.
|
|
1352
|
+
const localEndpointTrimmed = body.localEndpoint !== undefined
|
|
1353
|
+
? body.localEndpoint.trim()
|
|
1354
|
+
: undefined;
|
|
1355
|
+
if (localEndpointTrimmed !== undefined &&
|
|
1356
|
+
localEndpointTrimmed !== "") {
|
|
1357
|
+
if (localEndpointTrimmed.length > 2048) {
|
|
1358
|
+
respond400("localEndpoint must be ≤2048 characters");
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
let parsed;
|
|
1362
|
+
try {
|
|
1363
|
+
parsed = new URL(localEndpointTrimmed);
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
respond400("localEndpoint must be a valid http(s):// URL");
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1370
|
+
respond400("localEndpoint must use http:// or https:// scheme");
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (process.env.LOCAL_ENDPOINT_ALLOW_REMOTE !== "1" &&
|
|
1374
|
+
!isLoopbackOrPrivateEndpoint(localEndpointTrimmed)) {
|
|
1375
|
+
respond400("localEndpoint must be loopback or private (set LOCAL_ENDPOINT_ALLOW_REMOTE=1 to override)");
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1312
1379
|
// PHASE 2 — load config and apply all in-memory edits to `cfg`.
|
|
1313
1380
|
// Still no disk writes; if anything throws here the request
|
|
1314
1381
|
// returns 500 with no side effects.
|
|
@@ -1333,12 +1400,35 @@ export class Server extends EventEmitter {
|
|
|
1333
1400
|
}
|
|
1334
1401
|
if (body.model !== undefined) {
|
|
1335
1402
|
cfg.model = body.model;
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1403
|
+
}
|
|
1404
|
+
// localEndpoint / localModel persist regardless of whether
|
|
1405
|
+
// `model` is sent. Previously they were silently dropped when
|
|
1406
|
+
// the dashboard updated only the endpoint of an already-local
|
|
1407
|
+
// install, which left the running driver pointed at the old
|
|
1408
|
+
// host until the user happened to re-pick "Local LLM" in the
|
|
1409
|
+
// UI.
|
|
1410
|
+
if (body.localEndpoint !== undefined) {
|
|
1411
|
+
cfg.localEndpoint = body.localEndpoint.trim() || undefined;
|
|
1412
|
+
}
|
|
1413
|
+
if (body.localModel !== undefined) {
|
|
1414
|
+
cfg.localModel = body.localModel.trim() || undefined;
|
|
1415
|
+
}
|
|
1416
|
+
// Push / ntfy fields used to be set only on `this.*` and were
|
|
1417
|
+
// lost on bridge restart. Persist alongside the rest.
|
|
1418
|
+
if (pushUrlTrimmed !== undefined) {
|
|
1419
|
+
cfg.pushServiceUrl = pushUrlTrimmed || undefined;
|
|
1420
|
+
}
|
|
1421
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1422
|
+
cfg.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1423
|
+
}
|
|
1424
|
+
if (pushBaseTrimmed !== undefined) {
|
|
1425
|
+
cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
|
|
1426
|
+
}
|
|
1427
|
+
if (ntfyTopicTrimmed !== undefined) {
|
|
1428
|
+
cfg.ntfyTopic = ntfyTopicTrimmed || undefined;
|
|
1429
|
+
}
|
|
1430
|
+
if (ntfyServerTrimmed !== undefined) {
|
|
1431
|
+
cfg.ntfyServer = ntfyServerTrimmed || undefined;
|
|
1342
1432
|
}
|
|
1343
1433
|
// PHASE 3 — disk + secure-store writes. Order: secure store →
|
|
1344
1434
|
// bridge driver config → patchwork config. Each rolls back what
|
|
@@ -1348,7 +1438,19 @@ export class Server extends EventEmitter {
|
|
|
1348
1438
|
saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
|
|
1349
1439
|
}
|
|
1350
1440
|
catch (writeErr) {
|
|
1351
|
-
|
|
1441
|
+
// Some Keychain / Secret-Service backends echo the
|
|
1442
|
+
// payload into the error message on certain failure
|
|
1443
|
+
// modes. Cap the logged message at 200 chars AND
|
|
1444
|
+
// strip anything that looks like the leading hex/
|
|
1445
|
+
// base64 of the just-attempted key so a misbehaving
|
|
1446
|
+
// backend can't leak it into bridge logs.
|
|
1447
|
+
const raw = writeErr instanceof Error
|
|
1448
|
+
? writeErr.message
|
|
1449
|
+
: String(writeErr);
|
|
1450
|
+
const safe = raw
|
|
1451
|
+
.replaceAll(body.apiKey.key, "[redacted]")
|
|
1452
|
+
.slice(0, 200);
|
|
1453
|
+
this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${safe}`);
|
|
1352
1454
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1353
1455
|
res.end(JSON.stringify({ error: "Failed to write provider API key" }));
|
|
1354
1456
|
return;
|
|
@@ -1379,7 +1481,20 @@ export class Server extends EventEmitter {
|
|
|
1379
1481
|
// PHASE 4 — live state. Only after every persistence step
|
|
1380
1482
|
// succeeded; ensures live state never diverges from disk.
|
|
1381
1483
|
if (gateRaw !== undefined) {
|
|
1484
|
+
const prevGate = this.approvalGate;
|
|
1382
1485
|
this.approvalGate = gateRaw;
|
|
1486
|
+
// When the operator downgrades to "off", every pending
|
|
1487
|
+
// queue entry becomes moot — the originating tool dispatch
|
|
1488
|
+
// now bypasses, and a phone approver who hits "Approve"
|
|
1489
|
+
// five seconds later would get a 404. Cancel them
|
|
1490
|
+
// server-side so the dashboard /approvals list clears
|
|
1491
|
+
// and any pending phone notification reflects reality.
|
|
1492
|
+
if (gateRaw === "off" && prevGate !== "off") {
|
|
1493
|
+
const cancelled = getApprovalQueue().cancelAll();
|
|
1494
|
+
if (cancelled > 0) {
|
|
1495
|
+
this.logger.info(`[/settings] approvalGate → off; cancelled ${cancelled} pending entr${cancelled === 1 ? "y" : "ies"}`);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1383
1498
|
}
|
|
1384
1499
|
if (body.enableTimeOfDayAnomaly !== undefined) {
|
|
1385
1500
|
this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
@@ -1390,8 +1505,8 @@ export class Server extends EventEmitter {
|
|
|
1390
1505
|
if (pushUrlTrimmed !== undefined) {
|
|
1391
1506
|
this.pushServiceUrl = pushUrlTrimmed || undefined;
|
|
1392
1507
|
}
|
|
1393
|
-
if (
|
|
1394
|
-
this.pushServiceToken =
|
|
1508
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1509
|
+
this.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1395
1510
|
}
|
|
1396
1511
|
if (pushBaseTrimmed !== undefined) {
|
|
1397
1512
|
this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
|
|
@@ -1402,17 +1517,76 @@ export class Server extends EventEmitter {
|
|
|
1402
1517
|
if (ntfyServerTrimmed !== undefined) {
|
|
1403
1518
|
this.ntfyServer = ntfyServerTrimmed || undefined;
|
|
1404
1519
|
}
|
|
1520
|
+
// restartRequired covers fields the bridge only reads at boot:
|
|
1521
|
+
// driver/model/apiKey (env injection + driver factory), plus
|
|
1522
|
+
// localEndpoint/localModel which LocalApiDriver reads at
|
|
1523
|
+
// construction time. Push/ntfy fields are live-mutated on
|
|
1524
|
+
// `this.*` in PHASE 4 below, so they do not require restart.
|
|
1405
1525
|
const restartRequired = driverRaw !== undefined ||
|
|
1406
1526
|
body.apiKey !== undefined ||
|
|
1407
|
-
body.model !== undefined
|
|
1527
|
+
body.model !== undefined ||
|
|
1528
|
+
body.localEndpoint !== undefined ||
|
|
1529
|
+
body.localModel !== undefined;
|
|
1530
|
+
// Audit-log emission — forensic record of every config mutation
|
|
1531
|
+
// so an operator can answer "who changed what, when?" after an
|
|
1532
|
+
// incident. Bearer-token auth doesn't carry an actor identity
|
|
1533
|
+
// (no user JWT) so we attribute to "http" with the request IP.
|
|
1534
|
+
// Secrets are redacted to the shape `"***"` — value presence
|
|
1535
|
+
// is recorded, value content is never logged.
|
|
1536
|
+
if (this.activityLog) {
|
|
1537
|
+
const changes = {};
|
|
1538
|
+
if (hasWebhookUpdate)
|
|
1539
|
+
changes.webhookUrl = webhookRaw || "";
|
|
1540
|
+
if (gateRaw !== undefined)
|
|
1541
|
+
changes.approvalGate = gateRaw;
|
|
1542
|
+
if (body.enableTimeOfDayAnomaly !== undefined)
|
|
1543
|
+
changes.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
|
|
1544
|
+
if (driverRaw !== undefined)
|
|
1545
|
+
changes.driver = driverRaw;
|
|
1546
|
+
if (body.model !== undefined)
|
|
1547
|
+
changes.model = body.model;
|
|
1548
|
+
if (body.localEndpoint !== undefined)
|
|
1549
|
+
changes.localEndpoint = body.localEndpoint;
|
|
1550
|
+
if (body.localModel !== undefined)
|
|
1551
|
+
changes.localModel = body.localModel;
|
|
1552
|
+
if (body.apiKey)
|
|
1553
|
+
changes.apiKey = { provider: body.apiKey.provider, key: "***" };
|
|
1554
|
+
if (pushUrlTrimmed !== undefined)
|
|
1555
|
+
changes.pushServiceUrl = pushUrlTrimmed || "";
|
|
1556
|
+
if (pushTokenTrimmed !== undefined)
|
|
1557
|
+
changes.pushServiceToken = pushTokenTrimmed ? "***" : "";
|
|
1558
|
+
if (pushBaseTrimmed !== undefined)
|
|
1559
|
+
changes.pushServiceBaseUrl = pushBaseTrimmed || "";
|
|
1560
|
+
if (ntfyTopicTrimmed !== undefined)
|
|
1561
|
+
changes.ntfyTopic = ntfyTopicTrimmed ? "***" : "";
|
|
1562
|
+
if (ntfyServerTrimmed !== undefined)
|
|
1563
|
+
changes.ntfyServer = ntfyServerTrimmed || "";
|
|
1564
|
+
const remoteAddr = req.headers["x-forwarded-for"]
|
|
1565
|
+
?.split(",")[0]
|
|
1566
|
+
?.trim() ||
|
|
1567
|
+
req.socket.remoteAddress ||
|
|
1568
|
+
"unknown";
|
|
1569
|
+
this.activityLog.recordEvent("settings.change", {
|
|
1570
|
+
actor: "http",
|
|
1571
|
+
ip: remoteAddr,
|
|
1572
|
+
fields: Object.keys(changes),
|
|
1573
|
+
changes,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1408
1576
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1409
1577
|
res.end(JSON.stringify({ ok: true, restartRequired }));
|
|
1410
1578
|
}
|
|
1411
1579
|
}
|
|
1412
1580
|
catch (err) {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1581
|
+
// Any error reaching this outer catch is unexpected — all caller
|
|
1582
|
+
// validation errors already returned their own 400 inline, and all
|
|
1583
|
+
// expected disk-write errors return their own 500. So this is a
|
|
1584
|
+
// server bug, not a client one. Returning 400 here misled clients
|
|
1585
|
+
// into believing they had sent a bad body when in fact something
|
|
1586
|
+
// crashed serverside.
|
|
1587
|
+
this.logger.error(`[/config/patchwork] unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
1588
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1589
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
1416
1590
|
}
|
|
1417
1591
|
return;
|
|
1418
1592
|
}
|
|
@@ -1541,8 +1715,10 @@ export class Server extends EventEmitter {
|
|
|
1541
1715
|
}
|
|
1542
1716
|
}
|
|
1543
1717
|
// /telemetry-prefs — read/write per-flag telemetry preferences.
|
|
1544
|
-
// GET
|
|
1545
|
-
// POST
|
|
1718
|
+
// GET → {crashReports, usageStats, localDiagnostics, endpoint, endpointSource}
|
|
1719
|
+
// POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
|
|
1720
|
+
// DELETE → clears prefs file + analytics-config + install salt
|
|
1721
|
+
// (right-to-erasure affordance).
|
|
1546
1722
|
if (parsedUrl.pathname === "/telemetry-prefs") {
|
|
1547
1723
|
if (req.method === "GET") {
|
|
1548
1724
|
const prefs = getTelemetryPrefs();
|
|
@@ -1551,11 +1727,74 @@ export class Server extends EventEmitter {
|
|
|
1551
1727
|
if (all?.lastSentAt !== undefined) {
|
|
1552
1728
|
response.lastSentAt = all.lastSentAt;
|
|
1553
1729
|
}
|
|
1730
|
+
// Destination visibility — consent baseline. Caller can see
|
|
1731
|
+
// where their data would go and whether the destination came
|
|
1732
|
+
// from env (CI/headless) or the on-disk config file.
|
|
1733
|
+
const envEndpoint = process.env.PATCHWORK_ANALYTICS_ENDPOINT;
|
|
1734
|
+
const cfgEndpoint = getAnalyticsConfig().endpoint;
|
|
1735
|
+
const defaultEndpoint = "https://analytics.claude-ide-bridge.dev/v1/usage";
|
|
1736
|
+
if (envEndpoint) {
|
|
1737
|
+
response.endpoint = envEndpoint;
|
|
1738
|
+
response.endpointSource = "env";
|
|
1739
|
+
}
|
|
1740
|
+
else if (cfgEndpoint) {
|
|
1741
|
+
response.endpoint = cfgEndpoint;
|
|
1742
|
+
response.endpointSource = "config";
|
|
1743
|
+
}
|
|
1744
|
+
else {
|
|
1745
|
+
response.endpoint = defaultEndpoint;
|
|
1746
|
+
response.endpointSource = "default";
|
|
1747
|
+
}
|
|
1554
1748
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1555
1749
|
res.end(JSON.stringify(response));
|
|
1556
1750
|
return;
|
|
1557
1751
|
}
|
|
1752
|
+
if (req.method === "DELETE") {
|
|
1753
|
+
// Right-to-erasure: drop prefs, analytics-config (endpoint /
|
|
1754
|
+
// shared secret), and the install salt. Next outbound send
|
|
1755
|
+
// (if any) will mint a fresh salt and treat this as a new
|
|
1756
|
+
// install. Kill-switch is honored here too — incident-mode
|
|
1757
|
+
// operators may still want to scrub local state, so allow
|
|
1758
|
+
// it; flip if the threat model changes.
|
|
1759
|
+
try {
|
|
1760
|
+
unlinkSync(getAnalyticsPrefsPath());
|
|
1761
|
+
}
|
|
1762
|
+
catch {
|
|
1763
|
+
/* missing is fine */
|
|
1764
|
+
}
|
|
1765
|
+
try {
|
|
1766
|
+
unlinkSync(getAnalyticsSaltPath());
|
|
1767
|
+
}
|
|
1768
|
+
catch {
|
|
1769
|
+
/* missing is fine */
|
|
1770
|
+
}
|
|
1771
|
+
clearAnalyticsConfig();
|
|
1772
|
+
if (this.activityLog) {
|
|
1773
|
+
this.activityLog.recordEvent("telemetry.reset", {
|
|
1774
|
+
actor: "http",
|
|
1775
|
+
ip: req.headers["x-forwarded-for"]
|
|
1776
|
+
?.split(",")[0]
|
|
1777
|
+
?.trim() ||
|
|
1778
|
+
req.socket.remoteAddress ||
|
|
1779
|
+
"unknown",
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1783
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1558
1786
|
if (req.method === "POST") {
|
|
1787
|
+
// Kill-switch gate — telemetry prefs are config writes too.
|
|
1788
|
+
// GET stays open so operators can verify state during an
|
|
1789
|
+
// incident.
|
|
1790
|
+
if (isWriteKillSwitchActive()) {
|
|
1791
|
+
res.writeHead(423, { "Content-Type": "application/json" });
|
|
1792
|
+
res.end(JSON.stringify({
|
|
1793
|
+
error: "kill_switch_blocked",
|
|
1794
|
+
reason: "Telemetry pref writes are disabled while the write kill-switch is engaged.",
|
|
1795
|
+
}));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1559
1798
|
const TP_BODY_CAP = 1 * 1024;
|
|
1560
1799
|
const parsed = await readJsonBody(req, TP_BODY_CAP);
|
|
1561
1800
|
if (!parsed.ok) {
|
|
@@ -1576,6 +1815,25 @@ export class Server extends EventEmitter {
|
|
|
1576
1815
|
return;
|
|
1577
1816
|
}
|
|
1578
1817
|
const update = {};
|
|
1818
|
+
// Reject non-boolean values for known keys instead of silently
|
|
1819
|
+
// dropping them. Previously `{"crashReports": "true"}` (string)
|
|
1820
|
+
// got a 200 with no effect — caller never learned the toggle
|
|
1821
|
+
// did nothing. Misrepresented consent is the worst-case
|
|
1822
|
+
// outcome on the telemetry surface, so be strict here.
|
|
1823
|
+
for (const key of [
|
|
1824
|
+
"crashReports",
|
|
1825
|
+
"usageStats",
|
|
1826
|
+
"localDiagnostics",
|
|
1827
|
+
]) {
|
|
1828
|
+
if (body[key] !== undefined && typeof body[key] !== "boolean") {
|
|
1829
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1830
|
+
res.end(JSON.stringify({
|
|
1831
|
+
error: "invalid_request",
|
|
1832
|
+
reason: `${key} must be a boolean`,
|
|
1833
|
+
}));
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1579
1837
|
if (typeof body.crashReports === "boolean") {
|
|
1580
1838
|
update.crashReports = body.crashReports;
|
|
1581
1839
|
}
|