patchwork-os 0.2.0-beta.5.canary.2 → 0.2.0-beta.5.canary.21

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/dist/server.js CHANGED
@@ -9,7 +9,9 @@ import { saveBridgeConfigDriver } from "./config.js";
9
9
  import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
10
10
  import { timingSafeStringEqual } from "./crypto.js";
11
11
  import { renderDashboardHtml } from "./dashboard.js";
12
+ import { isLoopbackOrPrivateEndpoint } from "./drivers/local/index.js";
12
13
  import { EnvLockedFlagError, getEnvLockedValue, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, setFlag, } from "./featureFlags.js";
14
+ import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
13
15
  import { respond500 } from "./httpErrorResponse.js";
14
16
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
15
17
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
@@ -1157,6 +1159,21 @@ export class Server extends EventEmitter {
1157
1159
  return;
1158
1160
  }
1159
1161
  if (parsedUrl.pathname === "/settings" && req.method === "POST") {
1162
+ // Kill-switch gate: during an incident a settings mutation can
1163
+ // defeat the panic posture (e.g. switching driver to one with a
1164
+ // leaked API key, flipping approvalGate to "off"). The /settings
1165
+ // card on the dashboard sits directly above the kill-switch
1166
+ // toggle — users will reasonably read "writes blocked" as
1167
+ // covering both. Refuse with 423 Locked; the /kill-switch
1168
+ // endpoint itself is the only way out and is not gated.
1169
+ if (isWriteKillSwitchActive()) {
1170
+ res.writeHead(423, { "Content-Type": "application/json" });
1171
+ res.end(JSON.stringify({
1172
+ error: "kill_switch_blocked",
1173
+ reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
1174
+ }));
1175
+ return;
1176
+ }
1160
1177
  // 16 KB — settings POSTs are short-string fields (URLs, API keys,
1161
1178
  // gate level). 16 KB is generous; an authenticated attacker can't
1162
1179
  // stream gigabytes here.
@@ -1174,55 +1191,59 @@ export class Server extends EventEmitter {
1174
1191
  try {
1175
1192
  {
1176
1193
  const body = parsed.value ?? {};
1194
+ if (respondIfUnknownBodyKeys(res, body, [
1195
+ "webhookUrl",
1196
+ "approvalGate",
1197
+ "enableTimeOfDayAnomaly",
1198
+ "driver",
1199
+ "model",
1200
+ "localEndpoint",
1201
+ "localModel",
1202
+ "apiKey",
1203
+ "pushServiceUrl",
1204
+ "pushServiceToken",
1205
+ "pushServiceBaseUrl",
1206
+ "ntfyTopic",
1207
+ "ntfyServer",
1208
+ ])) {
1209
+ return;
1210
+ }
1211
+ // PHASE 1 — validate ALL fields up front. No disk writes, no
1212
+ // secure-store writes, no live-state mutations until every input
1213
+ // has passed. Prevents the "valid driver + invalid ntfyTopic"
1214
+ // class of bug where a 400 still leaves a partial side-effect on
1215
+ // disk.
1216
+ const respond400 = (error) => {
1217
+ res.writeHead(400, { "Content-Type": "application/json" });
1218
+ res.end(JSON.stringify({ error }));
1219
+ };
1220
+ // webhookUrl
1177
1221
  const hasWebhookUpdate = body.webhookUrl !== undefined;
1178
- const raw = hasWebhookUpdate
1222
+ const webhookRaw = hasWebhookUpdate
1179
1223
  ? (body.webhookUrl?.trim() ?? "")
1180
1224
  : undefined;
1181
- if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
1182
- res.writeHead(400, { "Content-Type": "application/json" });
1183
- res.end(JSON.stringify({ error: "webhookUrl must be HTTPS" }));
1225
+ if (webhookRaw !== undefined &&
1226
+ webhookRaw !== "" &&
1227
+ !/^https:\/\/.+/.test(webhookRaw)) {
1228
+ respond400("webhookUrl must be HTTPS");
1184
1229
  return;
1185
1230
  }
1231
+ // approvalGate
1186
1232
  const gateRaw = body.approvalGate;
1187
1233
  if (gateRaw !== undefined &&
1188
1234
  gateRaw !== "off" &&
1189
1235
  gateRaw !== "high" &&
1190
1236
  gateRaw !== "all") {
1191
- res.writeHead(400, { "Content-Type": "application/json" });
1192
- res.end(JSON.stringify({
1193
- error: 'approvalGate must be "off", "high", or "all"',
1194
- }));
1237
+ respond400('approvalGate must be "off", "high", or "all"');
1195
1238
  return;
1196
1239
  }
1197
- const configPath = patchworkConfigPath();
1198
- const cfg = loadPatchworkConfig(configPath);
1199
- cfg.dashboard = {
1200
- port: cfg.dashboard?.port ?? 3200,
1201
- requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1202
- pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1203
- webhookUrl: hasWebhookUpdate
1204
- ? raw || undefined
1205
- : cfg.dashboard?.webhookUrl,
1206
- };
1207
- if (gateRaw !== undefined) {
1208
- cfg.approvalGate = gateRaw;
1209
- this.approvalGate = gateRaw;
1210
- }
1211
- // h10 toggle: must be boolean if present. Persists to
1212
- // ~/.patchwork/config.json AND live-mutates the Server
1213
- // field so the next /approvals POST honors it without
1214
- // needing a bridge restart.
1215
- if (body.enableTimeOfDayAnomaly !== undefined) {
1216
- if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
1217
- res.writeHead(400, { "Content-Type": "application/json" });
1218
- res.end(JSON.stringify({
1219
- error: "enableTimeOfDayAnomaly must be a boolean",
1220
- }));
1221
- return;
1222
- }
1223
- cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1224
- this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1240
+ // enableTimeOfDayAnomaly
1241
+ if (body.enableTimeOfDayAnomaly !== undefined &&
1242
+ typeof body.enableTimeOfDayAnomaly !== "boolean") {
1243
+ respond400("enableTimeOfDayAnomaly must be a boolean");
1244
+ return;
1225
1245
  }
1246
+ // driver
1226
1247
  const driverRaw = body.driver;
1227
1248
  if (driverRaw !== undefined) {
1228
1249
  const validDrivers = [
@@ -1236,26 +1257,11 @@ export class Server extends EventEmitter {
1236
1257
  "none",
1237
1258
  ];
1238
1259
  if (!validDrivers.includes(driverRaw)) {
1239
- res.writeHead(400, { "Content-Type": "application/json" });
1240
- res.end(JSON.stringify({
1241
- error: `driver must be one of: ${validDrivers.join(", ")}`,
1242
- }));
1243
- return;
1244
- }
1245
- const driver = driverRaw;
1246
- cfg.driver = driver;
1247
- try {
1248
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1249
- }
1250
- catch (writeErr) {
1251
- this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1252
- res.writeHead(500, { "Content-Type": "application/json" });
1253
- res.end(JSON.stringify({
1254
- error: "Failed to write bridge driver config",
1255
- }));
1260
+ respond400(`driver must be one of: ${validDrivers.join(", ")}`);
1256
1261
  return;
1257
1262
  }
1258
1263
  }
1264
+ // model
1259
1265
  if (body.model !== undefined) {
1260
1266
  const validModels = [
1261
1267
  "claude",
@@ -1265,40 +1271,172 @@ export class Server extends EventEmitter {
1265
1271
  "local",
1266
1272
  ];
1267
1273
  if (!validModels.includes(body.model)) {
1268
- res.writeHead(400, { "Content-Type": "application/json" });
1269
- res.end(JSON.stringify({
1270
- error: `model must be one of: ${validModels.join(", ")}`,
1271
- }));
1274
+ respond400(`model must be one of: ${validModels.join(", ")}`);
1272
1275
  return;
1273
1276
  }
1274
- cfg.model = body.model;
1275
- if (body.model === "local") {
1276
- if (body.localEndpoint !== undefined)
1277
- cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1278
- if (body.localModel !== undefined)
1279
- cfg.localModel = body.localModel.trim() || undefined;
1280
- }
1281
1277
  }
1278
+ // apiKey
1282
1279
  if (body.apiKey) {
1283
1280
  const { provider, key } = body.apiKey;
1284
1281
  const validProviders = ["anthropic", "openai", "google", "xai"];
1285
1282
  if (!validProviders.includes(provider) ||
1286
1283
  typeof key !== "string") {
1287
- res.writeHead(400, { "Content-Type": "application/json" });
1288
- res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1284
+ respond400("Invalid apiKey provider or key");
1285
+ return;
1286
+ }
1287
+ }
1288
+ // pushServiceUrl
1289
+ const pushUrlTrimmed = body.pushServiceUrl !== undefined
1290
+ ? body.pushServiceUrl.trim()
1291
+ : undefined;
1292
+ if (pushUrlTrimmed !== undefined &&
1293
+ pushUrlTrimmed !== "" &&
1294
+ !pushUrlTrimmed.startsWith("https://")) {
1295
+ respond400("pushServiceUrl must be HTTPS");
1296
+ return;
1297
+ }
1298
+ // pushServiceBaseUrl — bridge callback origin embedded in SW
1299
+ // approveUrl/rejectUrl. http:// or attacker host = approval token
1300
+ // exfiltration. Validate before persisting.
1301
+ const pushBaseTrimmed = body.pushServiceBaseUrl !== undefined
1302
+ ? body.pushServiceBaseUrl.trim()
1303
+ : undefined;
1304
+ if (pushBaseTrimmed !== undefined &&
1305
+ pushBaseTrimmed !== "" &&
1306
+ !pushBaseTrimmed.startsWith("https://")) {
1307
+ respond400("pushServiceBaseUrl must be HTTPS");
1308
+ return;
1309
+ }
1310
+ // ntfyTopic — bearer-token on public ntfy.sh; charset-restricted.
1311
+ const ntfyTopicTrimmed = body.ntfyTopic !== undefined ? body.ntfyTopic.trim() : undefined;
1312
+ if (ntfyTopicTrimmed !== undefined &&
1313
+ ntfyTopicTrimmed !== "" &&
1314
+ !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopicTrimmed)) {
1315
+ respond400("ntfyTopic must match [A-Za-z0-9_-]{1,64}");
1316
+ return;
1317
+ }
1318
+ // ntfyServer — same threat model as pushServiceBaseUrl.
1319
+ const ntfyServerTrimmed = body.ntfyServer !== undefined
1320
+ ? body.ntfyServer.trim()
1321
+ : undefined;
1322
+ if (ntfyServerTrimmed !== undefined &&
1323
+ ntfyServerTrimmed !== "" &&
1324
+ !ntfyServerTrimmed.startsWith("https://")) {
1325
+ respond400("ntfyServer must be HTTPS");
1326
+ return;
1327
+ }
1328
+ // localEndpoint — used by LocalApiDriver as the inference base
1329
+ // URL. Must parse as http(s)://, be ≤2048 chars, and resolve to
1330
+ // a loopback or private address. Otherwise prompts + context
1331
+ // would stream to a public / metadata / file:// host. Same gate
1332
+ // the driver enforces at construction, raised to the HTTP
1333
+ // boundary so the bad value never reaches disk. Operator can
1334
+ // opt out via LOCAL_ENDPOINT_ALLOW_REMOTE=1 for audited
1335
+ // internal inference clusters.
1336
+ const localEndpointTrimmed = body.localEndpoint !== undefined
1337
+ ? body.localEndpoint.trim()
1338
+ : undefined;
1339
+ if (localEndpointTrimmed !== undefined &&
1340
+ localEndpointTrimmed !== "") {
1341
+ if (localEndpointTrimmed.length > 2048) {
1342
+ respond400("localEndpoint must be ≤2048 characters");
1343
+ return;
1344
+ }
1345
+ let parsed;
1346
+ try {
1347
+ parsed = new URL(localEndpointTrimmed);
1348
+ }
1349
+ catch {
1350
+ respond400("localEndpoint must be a valid http(s):// URL");
1351
+ return;
1352
+ }
1353
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1354
+ respond400("localEndpoint must use http:// or https:// scheme");
1355
+ return;
1356
+ }
1357
+ if (process.env.LOCAL_ENDPOINT_ALLOW_REMOTE !== "1" &&
1358
+ !isLoopbackOrPrivateEndpoint(localEndpointTrimmed)) {
1359
+ respond400("localEndpoint must be loopback or private (set LOCAL_ENDPOINT_ALLOW_REMOTE=1 to override)");
1360
+ return;
1361
+ }
1362
+ }
1363
+ // PHASE 2 — load config and apply all in-memory edits to `cfg`.
1364
+ // Still no disk writes; if anything throws here the request
1365
+ // returns 500 with no side effects.
1366
+ const configPath = patchworkConfigPath();
1367
+ const cfg = loadPatchworkConfig(configPath);
1368
+ cfg.dashboard = {
1369
+ port: cfg.dashboard?.port ?? 3200,
1370
+ requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1371
+ pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1372
+ webhookUrl: hasWebhookUpdate
1373
+ ? webhookRaw || undefined
1374
+ : cfg.dashboard?.webhookUrl,
1375
+ };
1376
+ if (gateRaw !== undefined) {
1377
+ cfg.approvalGate = gateRaw;
1378
+ }
1379
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1380
+ cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1381
+ }
1382
+ if (driverRaw !== undefined) {
1383
+ cfg.driver = driverRaw;
1384
+ }
1385
+ if (body.model !== undefined) {
1386
+ cfg.model = body.model;
1387
+ }
1388
+ // localEndpoint / localModel persist regardless of whether
1389
+ // `model` is sent. Previously they were silently dropped when
1390
+ // the dashboard updated only the endpoint of an already-local
1391
+ // install, which left the running driver pointed at the old
1392
+ // host until the user happened to re-pick "Local LLM" in the
1393
+ // UI.
1394
+ if (body.localEndpoint !== undefined) {
1395
+ cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1396
+ }
1397
+ if (body.localModel !== undefined) {
1398
+ cfg.localModel = body.localModel.trim() || undefined;
1399
+ }
1400
+ // Push / ntfy fields used to be set only on `this.*` and were
1401
+ // lost on bridge restart. Persist alongside the rest.
1402
+ if (pushUrlTrimmed !== undefined) {
1403
+ cfg.pushServiceUrl = pushUrlTrimmed || undefined;
1404
+ }
1405
+ if (body.pushServiceToken !== undefined) {
1406
+ cfg.pushServiceToken = body.pushServiceToken.trim() || undefined;
1407
+ }
1408
+ if (pushBaseTrimmed !== undefined) {
1409
+ cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1410
+ }
1411
+ if (ntfyTopicTrimmed !== undefined) {
1412
+ cfg.ntfyTopic = ntfyTopicTrimmed || undefined;
1413
+ }
1414
+ if (ntfyServerTrimmed !== undefined) {
1415
+ cfg.ntfyServer = ntfyServerTrimmed || undefined;
1416
+ }
1417
+ // PHASE 3 — disk + secure-store writes. Order: secure store →
1418
+ // bridge driver config → patchwork config. Each rolls back what
1419
+ // it can on later failure (left to PR #02; for now log + 500).
1420
+ if (body.apiKey) {
1421
+ try {
1422
+ saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1423
+ }
1424
+ catch (writeErr) {
1425
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1426
+ res.writeHead(500, { "Content-Type": "application/json" });
1427
+ res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1289
1428
  return;
1290
1429
  }
1291
- // Provider keys go to the secure store (Keychain/DPAPI/Secret
1292
- // Service / AES-256-GCM file fallback) — never persisted to
1293
- // ~/.patchwork/config.json. Empty string clears.
1430
+ }
1431
+ if (driverRaw !== undefined) {
1294
1432
  try {
1295
- saveApiKeyToSecureStore(provider, key);
1433
+ saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
1296
1434
  }
1297
1435
  catch (writeErr) {
1298
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1436
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1299
1437
  res.writeHead(500, { "Content-Type": "application/json" });
1300
1438
  res.end(JSON.stringify({
1301
- error: "Failed to write provider API key",
1439
+ error: "Failed to write bridge driver config",
1302
1440
  }));
1303
1441
  return;
1304
1442
  }
@@ -1307,81 +1445,107 @@ export class Server extends EventEmitter {
1307
1445
  savePatchworkConfig(cfg, configPath);
1308
1446
  }
1309
1447
  catch (writeErr) {
1310
- this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1448
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1311
1449
  res.writeHead(500, { "Content-Type": "application/json" });
1312
- res.end(JSON.stringify({
1313
- error: "Failed to write patchwork config",
1314
- }));
1450
+ res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
1315
1451
  return;
1316
1452
  }
1453
+ // PHASE 4 — live state. Only after every persistence step
1454
+ // succeeded; ensures live state never diverges from disk.
1455
+ if (gateRaw !== undefined) {
1456
+ this.approvalGate = gateRaw;
1457
+ }
1458
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1459
+ this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1460
+ }
1317
1461
  if (hasWebhookUpdate) {
1318
- this.approvalWebhookUrl = raw || undefined;
1462
+ this.approvalWebhookUrl = webhookRaw || undefined;
1319
1463
  }
1320
- if (body.pushServiceUrl !== undefined) {
1321
- const pushUrl = body.pushServiceUrl.trim();
1322
- if (pushUrl && !pushUrl.startsWith("https://")) {
1323
- res.writeHead(400, { "Content-Type": "application/json" });
1324
- res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1325
- return;
1326
- }
1327
- this.pushServiceUrl = pushUrl || undefined;
1464
+ if (pushUrlTrimmed !== undefined) {
1465
+ this.pushServiceUrl = pushUrlTrimmed || undefined;
1328
1466
  }
1329
1467
  if (body.pushServiceToken !== undefined) {
1330
1468
  this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1331
1469
  }
1332
- if (body.pushServiceBaseUrl !== undefined) {
1333
- const baseUrl = body.pushServiceBaseUrl.trim();
1334
- // pushServiceBaseUrl is the bridge callback origin embedded in
1335
- // the SW's approveUrl/rejectUrl. If it can be set to plain
1336
- // http:// or to a host the operator didn't intend, the SW will
1337
- // POST the one-shot approvalToken there — letting an attacker
1338
- // who sets this redirect every approval to attacker.tld and
1339
- // replay tokens to the real bridge for silent auto-approve.
1340
- if (baseUrl && !baseUrl.startsWith("https://")) {
1341
- res.writeHead(400, { "Content-Type": "application/json" });
1342
- res.end(JSON.stringify({ error: "pushServiceBaseUrl must be HTTPS" }));
1343
- return;
1344
- }
1345
- this.pushServiceBaseUrl = baseUrl || undefined;
1470
+ if (pushBaseTrimmed !== undefined) {
1471
+ this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1346
1472
  }
1347
- if (body.ntfyTopic !== undefined) {
1348
- const topic = body.ntfyTopic.trim();
1349
- // Topic acts as a bearer token on the public ntfy.sh server —
1350
- // anyone subscribed sees the approval payload + single-use
1351
- // approvalToken. Reject empty / whitespace / control chars to
1352
- // avoid silent misconfiguration.
1353
- if (topic && !/^[A-Za-z0-9_-]{1,64}$/.test(topic)) {
1354
- res.writeHead(400, { "Content-Type": "application/json" });
1355
- res.end(JSON.stringify({
1356
- error: "ntfyTopic must match [A-Za-z0-9_-]{1,64}",
1357
- }));
1358
- return;
1359
- }
1360
- this.ntfyTopic = topic || undefined;
1473
+ if (ntfyTopicTrimmed !== undefined) {
1474
+ this.ntfyTopic = ntfyTopicTrimmed || undefined;
1361
1475
  }
1362
- if (body.ntfyServer !== undefined) {
1363
- const server = body.ntfyServer.trim();
1364
- // Same reasoning as pushServiceBaseUrl — the bridge sends the
1365
- // single-use token to this URL. http:// would expose it on the
1366
- // wire; a malicious value would exfiltrate every approval.
1367
- if (server && !server.startsWith("https://")) {
1368
- res.writeHead(400, { "Content-Type": "application/json" });
1369
- res.end(JSON.stringify({ error: "ntfyServer must be HTTPS" }));
1370
- return;
1371
- }
1372
- this.ntfyServer = server || undefined;
1476
+ if (ntfyServerTrimmed !== undefined) {
1477
+ this.ntfyServer = ntfyServerTrimmed || undefined;
1373
1478
  }
1479
+ // restartRequired covers fields the bridge only reads at boot:
1480
+ // driver/model/apiKey (env injection + driver factory), plus
1481
+ // localEndpoint/localModel which LocalApiDriver reads at
1482
+ // construction time. Push/ntfy fields are live-mutated on
1483
+ // `this.*` in PHASE 4 below, so they do not require restart.
1374
1484
  const restartRequired = driverRaw !== undefined ||
1375
1485
  body.apiKey !== undefined ||
1376
- body.model !== undefined;
1486
+ body.model !== undefined ||
1487
+ body.localEndpoint !== undefined ||
1488
+ body.localModel !== undefined;
1489
+ // Audit-log emission — forensic record of every config mutation
1490
+ // so an operator can answer "who changed what, when?" after an
1491
+ // incident. Bearer-token auth doesn't carry an actor identity
1492
+ // (no user JWT) so we attribute to "http" with the request IP.
1493
+ // Secrets are redacted to the shape `"***"` — value presence
1494
+ // is recorded, value content is never logged.
1495
+ if (this.activityLog) {
1496
+ const changes = {};
1497
+ if (hasWebhookUpdate)
1498
+ changes.webhookUrl = webhookRaw || "";
1499
+ if (gateRaw !== undefined)
1500
+ changes.approvalGate = gateRaw;
1501
+ if (body.enableTimeOfDayAnomaly !== undefined)
1502
+ changes.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1503
+ if (driverRaw !== undefined)
1504
+ changes.driver = driverRaw;
1505
+ if (body.model !== undefined)
1506
+ changes.model = body.model;
1507
+ if (body.localEndpoint !== undefined)
1508
+ changes.localEndpoint = body.localEndpoint;
1509
+ if (body.localModel !== undefined)
1510
+ changes.localModel = body.localModel;
1511
+ if (body.apiKey)
1512
+ changes.apiKey = { provider: body.apiKey.provider, key: "***" };
1513
+ if (pushUrlTrimmed !== undefined)
1514
+ changes.pushServiceUrl = pushUrlTrimmed || "";
1515
+ if (body.pushServiceToken !== undefined)
1516
+ changes.pushServiceToken = body.pushServiceToken ? "***" : "";
1517
+ if (pushBaseTrimmed !== undefined)
1518
+ changes.pushServiceBaseUrl = pushBaseTrimmed || "";
1519
+ if (ntfyTopicTrimmed !== undefined)
1520
+ changes.ntfyTopic = ntfyTopicTrimmed ? "***" : "";
1521
+ if (ntfyServerTrimmed !== undefined)
1522
+ changes.ntfyServer = ntfyServerTrimmed || "";
1523
+ const remoteAddr = req.headers["x-forwarded-for"]
1524
+ ?.split(",")[0]
1525
+ ?.trim() ||
1526
+ req.socket.remoteAddress ||
1527
+ "unknown";
1528
+ this.activityLog.recordEvent("settings.change", {
1529
+ actor: "http",
1530
+ ip: remoteAddr,
1531
+ fields: Object.keys(changes),
1532
+ changes,
1533
+ });
1534
+ }
1377
1535
  res.writeHead(200, { "Content-Type": "application/json" });
1378
1536
  res.end(JSON.stringify({ ok: true, restartRequired }));
1379
1537
  }
1380
1538
  }
1381
1539
  catch (err) {
1382
- this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1383
- res.writeHead(400, { "Content-Type": "application/json" });
1384
- res.end(JSON.stringify({ error: "Invalid request body" }));
1540
+ // Any error reaching this outer catch is unexpected all caller
1541
+ // validation errors already returned their own 400 inline, and all
1542
+ // expected disk-write errors return their own 500. So this is a
1543
+ // server bug, not a client one. Returning 400 here misled clients
1544
+ // into believing they had sent a bad body when in fact something
1545
+ // crashed serverside.
1546
+ this.logger.error(`[/config/patchwork] unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1547
+ res.writeHead(500, { "Content-Type": "application/json" });
1548
+ res.end(JSON.stringify({ error: "Internal server error" }));
1385
1549
  }
1386
1550
  return;
1387
1551
  }
@@ -1432,6 +1596,9 @@ export class Server extends EventEmitter {
1432
1596
  return;
1433
1597
  }
1434
1598
  const body = parsed.value ?? {};
1599
+ if (respondIfUnknownBodyKeys(res, body, ["engage", "reason"])) {
1600
+ return;
1601
+ }
1435
1602
  if (typeof body.engage !== "boolean") {
1436
1603
  res.writeHead(400, { "Content-Type": "application/json" });
1437
1604
  res.end(JSON.stringify({
@@ -1522,6 +1689,17 @@ export class Server extends EventEmitter {
1522
1689
  return;
1523
1690
  }
1524
1691
  if (req.method === "POST") {
1692
+ // Kill-switch gate — telemetry prefs are config writes too.
1693
+ // GET stays open so operators can verify state during an
1694
+ // incident.
1695
+ if (isWriteKillSwitchActive()) {
1696
+ res.writeHead(423, { "Content-Type": "application/json" });
1697
+ res.end(JSON.stringify({
1698
+ error: "kill_switch_blocked",
1699
+ reason: "Telemetry pref writes are disabled while the write kill-switch is engaged.",
1700
+ }));
1701
+ return;
1702
+ }
1525
1703
  const TP_BODY_CAP = 1 * 1024;
1526
1704
  const parsed = await readJsonBody(req, TP_BODY_CAP);
1527
1705
  if (!parsed.ok) {
@@ -1534,7 +1712,33 @@ export class Server extends EventEmitter {
1534
1712
  return;
1535
1713
  }
1536
1714
  const body = parsed.value ?? {};
1715
+ if (respondIfUnknownBodyKeys(res, body, [
1716
+ "crashReports",
1717
+ "usageStats",
1718
+ "localDiagnostics",
1719
+ ])) {
1720
+ return;
1721
+ }
1537
1722
  const update = {};
1723
+ // Reject non-boolean values for known keys instead of silently
1724
+ // dropping them. Previously `{"crashReports": "true"}` (string)
1725
+ // got a 200 with no effect — caller never learned the toggle
1726
+ // did nothing. Misrepresented consent is the worst-case
1727
+ // outcome on the telemetry surface, so be strict here.
1728
+ for (const key of [
1729
+ "crashReports",
1730
+ "usageStats",
1731
+ "localDiagnostics",
1732
+ ]) {
1733
+ if (body[key] !== undefined && typeof body[key] !== "boolean") {
1734
+ res.writeHead(400, { "Content-Type": "application/json" });
1735
+ res.end(JSON.stringify({
1736
+ error: "invalid_request",
1737
+ reason: `${key} must be a boolean`,
1738
+ }));
1739
+ return;
1740
+ }
1741
+ }
1538
1742
  if (typeof body.crashReports === "boolean") {
1539
1743
  update.crashReports = body.crashReports;
1540
1744
  }