patchwork-os 0.2.0-beta.5.canary.7 → 0.2.0-beta.5.canary.71

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
@@ -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 { 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";
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.
@@ -1192,55 +1210,42 @@ export class Server extends EventEmitter {
1192
1210
  ])) {
1193
1211
  return;
1194
1212
  }
1213
+ // PHASE 1 — validate ALL fields up front. No disk writes, no
1214
+ // secure-store writes, no live-state mutations until every input
1215
+ // has passed. Prevents the "valid driver + invalid ntfyTopic"
1216
+ // class of bug where a 400 still leaves a partial side-effect on
1217
+ // disk.
1218
+ const respond400 = (error) => {
1219
+ res.writeHead(400, { "Content-Type": "application/json" });
1220
+ res.end(JSON.stringify({ error }));
1221
+ };
1222
+ // webhookUrl
1195
1223
  const hasWebhookUpdate = body.webhookUrl !== undefined;
1196
- const raw = hasWebhookUpdate
1224
+ const webhookRaw = hasWebhookUpdate
1197
1225
  ? (body.webhookUrl?.trim() ?? "")
1198
1226
  : undefined;
1199
- if (raw !== undefined && raw !== "" && !/^https:\/\/.+/.test(raw)) {
1200
- res.writeHead(400, { "Content-Type": "application/json" });
1201
- res.end(JSON.stringify({ error: "webhookUrl must be HTTPS" }));
1227
+ if (webhookRaw !== undefined &&
1228
+ webhookRaw !== "" &&
1229
+ !/^https:\/\/.+/.test(webhookRaw)) {
1230
+ respond400("webhookUrl must be HTTPS");
1202
1231
  return;
1203
1232
  }
1233
+ // approvalGate
1204
1234
  const gateRaw = body.approvalGate;
1205
1235
  if (gateRaw !== undefined &&
1206
1236
  gateRaw !== "off" &&
1207
1237
  gateRaw !== "high" &&
1208
1238
  gateRaw !== "all") {
1209
- res.writeHead(400, { "Content-Type": "application/json" });
1210
- res.end(JSON.stringify({
1211
- error: 'approvalGate must be "off", "high", or "all"',
1212
- }));
1239
+ respond400('approvalGate must be "off", "high", or "all"');
1213
1240
  return;
1214
1241
  }
1215
- const configPath = patchworkConfigPath();
1216
- const cfg = loadPatchworkConfig(configPath);
1217
- cfg.dashboard = {
1218
- port: cfg.dashboard?.port ?? 3200,
1219
- requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1220
- pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1221
- webhookUrl: hasWebhookUpdate
1222
- ? raw || undefined
1223
- : cfg.dashboard?.webhookUrl,
1224
- };
1225
- if (gateRaw !== undefined) {
1226
- cfg.approvalGate = gateRaw;
1227
- this.approvalGate = gateRaw;
1228
- }
1229
- // h10 toggle: must be boolean if present. Persists to
1230
- // ~/.patchwork/config.json AND live-mutates the Server
1231
- // field so the next /approvals POST honors it without
1232
- // needing a bridge restart.
1233
- if (body.enableTimeOfDayAnomaly !== undefined) {
1234
- if (typeof body.enableTimeOfDayAnomaly !== "boolean") {
1235
- res.writeHead(400, { "Content-Type": "application/json" });
1236
- res.end(JSON.stringify({
1237
- error: "enableTimeOfDayAnomaly must be a boolean",
1238
- }));
1239
- return;
1240
- }
1241
- cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1242
- this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1242
+ // enableTimeOfDayAnomaly
1243
+ if (body.enableTimeOfDayAnomaly !== undefined &&
1244
+ typeof body.enableTimeOfDayAnomaly !== "boolean") {
1245
+ respond400("enableTimeOfDayAnomaly must be a boolean");
1246
+ return;
1243
1247
  }
1248
+ // driver
1244
1249
  const driverRaw = body.driver;
1245
1250
  if (driverRaw !== undefined) {
1246
1251
  const validDrivers = [
@@ -1254,26 +1259,11 @@ export class Server extends EventEmitter {
1254
1259
  "none",
1255
1260
  ];
1256
1261
  if (!validDrivers.includes(driverRaw)) {
1257
- res.writeHead(400, { "Content-Type": "application/json" });
1258
- res.end(JSON.stringify({
1259
- error: `driver must be one of: ${validDrivers.join(", ")}`,
1260
- }));
1261
- return;
1262
- }
1263
- const driver = driverRaw;
1264
- cfg.driver = driver;
1265
- try {
1266
- saveBridgeConfigDriver(driver, this.bridgeConfigPath);
1267
- }
1268
- catch (writeErr) {
1269
- this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1270
- res.writeHead(500, { "Content-Type": "application/json" });
1271
- res.end(JSON.stringify({
1272
- error: "Failed to write bridge driver config",
1273
- }));
1262
+ respond400(`driver must be one of: ${validDrivers.join(", ")}`);
1274
1263
  return;
1275
1264
  }
1276
1265
  }
1266
+ // model
1277
1267
  if (body.model !== undefined) {
1278
1268
  const validModels = [
1279
1269
  "claude",
@@ -1283,40 +1273,198 @@ export class Server extends EventEmitter {
1283
1273
  "local",
1284
1274
  ];
1285
1275
  if (!validModels.includes(body.model)) {
1286
- res.writeHead(400, { "Content-Type": "application/json" });
1287
- res.end(JSON.stringify({
1288
- error: `model must be one of: ${validModels.join(", ")}`,
1289
- }));
1276
+ respond400(`model must be one of: ${validModels.join(", ")}`);
1290
1277
  return;
1291
1278
  }
1292
- cfg.model = body.model;
1293
- if (body.model === "local") {
1294
- if (body.localEndpoint !== undefined)
1295
- cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1296
- if (body.localModel !== undefined)
1297
- cfg.localModel = body.localModel.trim() || undefined;
1298
- }
1299
1279
  }
1280
+ // apiKey
1300
1281
  if (body.apiKey) {
1301
1282
  const { provider, key } = body.apiKey;
1302
1283
  const validProviders = ["anthropic", "openai", "google", "xai"];
1303
1284
  if (!validProviders.includes(provider) ||
1304
1285
  typeof key !== "string") {
1305
- res.writeHead(400, { "Content-Type": "application/json" });
1306
- res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1286
+ respond400("Invalid apiKey provider or key");
1307
1287
  return;
1308
1288
  }
1309
- // Provider keys go to the secure store (Keychain/DPAPI/Secret
1310
- // Service / AES-256-GCM file fallback) — never persisted to
1311
- // ~/.patchwork/config.json. Empty string clears.
1289
+ }
1290
+ // pushServiceUrl
1291
+ const pushUrlTrimmed = body.pushServiceUrl !== undefined
1292
+ ? body.pushServiceUrl.trim()
1293
+ : undefined;
1294
+ if (pushUrlTrimmed !== undefined &&
1295
+ pushUrlTrimmed !== "" &&
1296
+ !pushUrlTrimmed.startsWith("https://")) {
1297
+ respond400("pushServiceUrl must be HTTPS");
1298
+ return;
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
+ }
1314
+ // pushServiceBaseUrl — bridge callback origin embedded in SW
1315
+ // approveUrl/rejectUrl. http:// or attacker host = approval token
1316
+ // exfiltration. Validate before persisting.
1317
+ const pushBaseTrimmed = body.pushServiceBaseUrl !== undefined
1318
+ ? body.pushServiceBaseUrl.trim()
1319
+ : undefined;
1320
+ if (pushBaseTrimmed !== undefined &&
1321
+ pushBaseTrimmed !== "" &&
1322
+ !pushBaseTrimmed.startsWith("https://")) {
1323
+ respond400("pushServiceBaseUrl must be HTTPS");
1324
+ return;
1325
+ }
1326
+ // ntfyTopic — bearer-token on public ntfy.sh; charset-restricted.
1327
+ const ntfyTopicTrimmed = body.ntfyTopic !== undefined ? body.ntfyTopic.trim() : undefined;
1328
+ if (ntfyTopicTrimmed !== undefined &&
1329
+ ntfyTopicTrimmed !== "" &&
1330
+ !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopicTrimmed)) {
1331
+ respond400("ntfyTopic must match [A-Za-z0-9_-]{1,64}");
1332
+ return;
1333
+ }
1334
+ // ntfyServer — same threat model as pushServiceBaseUrl.
1335
+ const ntfyServerTrimmed = body.ntfyServer !== undefined
1336
+ ? body.ntfyServer.trim()
1337
+ : undefined;
1338
+ if (ntfyServerTrimmed !== undefined &&
1339
+ ntfyServerTrimmed !== "" &&
1340
+ !ntfyServerTrimmed.startsWith("https://")) {
1341
+ respond400("ntfyServer must be HTTPS");
1342
+ return;
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;
1312
1362
  try {
1313
- saveApiKeyToSecureStore(provider, key);
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
+ }
1379
+ // PHASE 2 — load config and apply all in-memory edits to `cfg`.
1380
+ // Still no disk writes; if anything throws here the request
1381
+ // returns 500 with no side effects.
1382
+ const configPath = patchworkConfigPath();
1383
+ const cfg = loadPatchworkConfig(configPath);
1384
+ cfg.dashboard = {
1385
+ port: cfg.dashboard?.port ?? 3200,
1386
+ requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1387
+ pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1388
+ webhookUrl: hasWebhookUpdate
1389
+ ? webhookRaw || undefined
1390
+ : cfg.dashboard?.webhookUrl,
1391
+ };
1392
+ if (gateRaw !== undefined) {
1393
+ cfg.approvalGate = gateRaw;
1394
+ }
1395
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1396
+ cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1397
+ }
1398
+ if (driverRaw !== undefined) {
1399
+ cfg.driver = driverRaw;
1400
+ }
1401
+ if (body.model !== undefined) {
1402
+ cfg.model = body.model;
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;
1432
+ }
1433
+ // PHASE 3 — disk + secure-store writes. Order: secure store →
1434
+ // bridge driver config → patchwork config. Each rolls back what
1435
+ // it can on later failure (left to PR #02; for now log + 500).
1436
+ if (body.apiKey) {
1437
+ try {
1438
+ saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1314
1439
  }
1315
1440
  catch (writeErr) {
1316
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
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}`);
1454
+ res.writeHead(500, { "Content-Type": "application/json" });
1455
+ res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1456
+ return;
1457
+ }
1458
+ }
1459
+ if (driverRaw !== undefined) {
1460
+ try {
1461
+ saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
1462
+ }
1463
+ catch (writeErr) {
1464
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1317
1465
  res.writeHead(500, { "Content-Type": "application/json" });
1318
1466
  res.end(JSON.stringify({
1319
- error: "Failed to write provider API key",
1467
+ error: "Failed to write bridge driver config",
1320
1468
  }));
1321
1469
  return;
1322
1470
  }
@@ -1325,81 +1473,120 @@ export class Server extends EventEmitter {
1325
1473
  savePatchworkConfig(cfg, configPath);
1326
1474
  }
1327
1475
  catch (writeErr) {
1328
- this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1476
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1329
1477
  res.writeHead(500, { "Content-Type": "application/json" });
1330
- res.end(JSON.stringify({
1331
- error: "Failed to write patchwork config",
1332
- }));
1478
+ res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
1333
1479
  return;
1334
1480
  }
1481
+ // PHASE 4 — live state. Only after every persistence step
1482
+ // succeeded; ensures live state never diverges from disk.
1483
+ if (gateRaw !== undefined) {
1484
+ const prevGate = this.approvalGate;
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
+ }
1498
+ }
1499
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1500
+ this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1501
+ }
1335
1502
  if (hasWebhookUpdate) {
1336
- this.approvalWebhookUrl = raw || undefined;
1503
+ this.approvalWebhookUrl = webhookRaw || undefined;
1337
1504
  }
1338
- if (body.pushServiceUrl !== undefined) {
1339
- const pushUrl = body.pushServiceUrl.trim();
1340
- if (pushUrl && !pushUrl.startsWith("https://")) {
1341
- res.writeHead(400, { "Content-Type": "application/json" });
1342
- res.end(JSON.stringify({ error: "pushServiceUrl must be HTTPS" }));
1343
- return;
1344
- }
1345
- this.pushServiceUrl = pushUrl || undefined;
1505
+ if (pushUrlTrimmed !== undefined) {
1506
+ this.pushServiceUrl = pushUrlTrimmed || undefined;
1346
1507
  }
1347
- if (body.pushServiceToken !== undefined) {
1348
- this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1508
+ if (pushTokenTrimmed !== undefined) {
1509
+ this.pushServiceToken = pushTokenTrimmed || undefined;
1349
1510
  }
1350
- if (body.pushServiceBaseUrl !== undefined) {
1351
- const baseUrl = body.pushServiceBaseUrl.trim();
1352
- // pushServiceBaseUrl is the bridge callback origin embedded in
1353
- // the SW's approveUrl/rejectUrl. If it can be set to plain
1354
- // http:// or to a host the operator didn't intend, the SW will
1355
- // POST the one-shot approvalToken there — letting an attacker
1356
- // who sets this redirect every approval to attacker.tld and
1357
- // replay tokens to the real bridge for silent auto-approve.
1358
- if (baseUrl && !baseUrl.startsWith("https://")) {
1359
- res.writeHead(400, { "Content-Type": "application/json" });
1360
- res.end(JSON.stringify({ error: "pushServiceBaseUrl must be HTTPS" }));
1361
- return;
1362
- }
1363
- this.pushServiceBaseUrl = baseUrl || undefined;
1511
+ if (pushBaseTrimmed !== undefined) {
1512
+ this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1364
1513
  }
1365
- if (body.ntfyTopic !== undefined) {
1366
- const topic = body.ntfyTopic.trim();
1367
- // Topic acts as a bearer token on the public ntfy.sh server —
1368
- // anyone subscribed sees the approval payload + single-use
1369
- // approvalToken. Reject empty / whitespace / control chars to
1370
- // avoid silent misconfiguration.
1371
- if (topic && !/^[A-Za-z0-9_-]{1,64}$/.test(topic)) {
1372
- res.writeHead(400, { "Content-Type": "application/json" });
1373
- res.end(JSON.stringify({
1374
- error: "ntfyTopic must match [A-Za-z0-9_-]{1,64}",
1375
- }));
1376
- return;
1377
- }
1378
- this.ntfyTopic = topic || undefined;
1514
+ if (ntfyTopicTrimmed !== undefined) {
1515
+ this.ntfyTopic = ntfyTopicTrimmed || undefined;
1379
1516
  }
1380
- if (body.ntfyServer !== undefined) {
1381
- const server = body.ntfyServer.trim();
1382
- // Same reasoning as pushServiceBaseUrl — the bridge sends the
1383
- // single-use token to this URL. http:// would expose it on the
1384
- // wire; a malicious value would exfiltrate every approval.
1385
- if (server && !server.startsWith("https://")) {
1386
- res.writeHead(400, { "Content-Type": "application/json" });
1387
- res.end(JSON.stringify({ error: "ntfyServer must be HTTPS" }));
1388
- return;
1389
- }
1390
- this.ntfyServer = server || undefined;
1517
+ if (ntfyServerTrimmed !== undefined) {
1518
+ this.ntfyServer = ntfyServerTrimmed || undefined;
1391
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.
1392
1525
  const restartRequired = driverRaw !== undefined ||
1393
1526
  body.apiKey !== undefined ||
1394
- 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
+ }
1395
1576
  res.writeHead(200, { "Content-Type": "application/json" });
1396
1577
  res.end(JSON.stringify({ ok: true, restartRequired }));
1397
1578
  }
1398
1579
  }
1399
1580
  catch (err) {
1400
- this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1401
- res.writeHead(400, { "Content-Type": "application/json" });
1402
- res.end(JSON.stringify({ error: "Invalid request body" }));
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" }));
1403
1590
  }
1404
1591
  return;
1405
1592
  }
@@ -1528,8 +1715,10 @@ export class Server extends EventEmitter {
1528
1715
  }
1529
1716
  }
1530
1717
  // /telemetry-prefs — read/write per-flag telemetry preferences.
1531
- // GET → {crashReports, usageStats, localDiagnostics}
1532
- // POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
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).
1533
1722
  if (parsedUrl.pathname === "/telemetry-prefs") {
1534
1723
  if (req.method === "GET") {
1535
1724
  const prefs = getTelemetryPrefs();
@@ -1538,11 +1727,74 @@ export class Server extends EventEmitter {
1538
1727
  if (all?.lastSentAt !== undefined) {
1539
1728
  response.lastSentAt = all.lastSentAt;
1540
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
+ }
1541
1748
  res.writeHead(200, { "Content-Type": "application/json" });
1542
1749
  res.end(JSON.stringify(response));
1543
1750
  return;
1544
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
+ }
1545
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
+ }
1546
1798
  const TP_BODY_CAP = 1 * 1024;
1547
1799
  const parsed = await readJsonBody(req, TP_BODY_CAP);
1548
1800
  if (!parsed.ok) {
@@ -1563,6 +1815,25 @@ export class Server extends EventEmitter {
1563
1815
  return;
1564
1816
  }
1565
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
+ }
1566
1837
  if (typeof body.crashReports === "boolean") {
1567
1838
  update.crashReports = body.crashReports;
1568
1839
  }