patchwork-os 0.2.0-beta.5 → 0.2.0-beta.5.canary.10

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
@@ -10,6 +10,7 @@ import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./conne
10
10
  import { timingSafeStringEqual } from "./crypto.js";
11
11
  import { renderDashboardHtml } from "./dashboard.js";
12
12
  import { EnvLockedFlagError, getEnvLockedValue, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, setFlag, } from "./featureFlags.js";
13
+ import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
13
14
  import { respond500 } from "./httpErrorResponse.js";
14
15
  import { tryHandleInboxRoute } from "./inboxRoutes.js";
15
16
  import { tryHandleMcpRoute } from "./mcpRoutes.js";
@@ -1174,55 +1175,59 @@ export class Server extends EventEmitter {
1174
1175
  try {
1175
1176
  {
1176
1177
  const body = parsed.value ?? {};
1178
+ if (respondIfUnknownBodyKeys(res, body, [
1179
+ "webhookUrl",
1180
+ "approvalGate",
1181
+ "enableTimeOfDayAnomaly",
1182
+ "driver",
1183
+ "model",
1184
+ "localEndpoint",
1185
+ "localModel",
1186
+ "apiKey",
1187
+ "pushServiceUrl",
1188
+ "pushServiceToken",
1189
+ "pushServiceBaseUrl",
1190
+ "ntfyTopic",
1191
+ "ntfyServer",
1192
+ ])) {
1193
+ return;
1194
+ }
1195
+ // PHASE 1 — validate ALL fields up front. No disk writes, no
1196
+ // secure-store writes, no live-state mutations until every input
1197
+ // has passed. Prevents the "valid driver + invalid ntfyTopic"
1198
+ // class of bug where a 400 still leaves a partial side-effect on
1199
+ // disk.
1200
+ const respond400 = (error) => {
1201
+ res.writeHead(400, { "Content-Type": "application/json" });
1202
+ res.end(JSON.stringify({ error }));
1203
+ };
1204
+ // webhookUrl
1177
1205
  const hasWebhookUpdate = body.webhookUrl !== undefined;
1178
- const raw = hasWebhookUpdate
1206
+ const webhookRaw = hasWebhookUpdate
1179
1207
  ? (body.webhookUrl?.trim() ?? "")
1180
1208
  : 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" }));
1209
+ if (webhookRaw !== undefined &&
1210
+ webhookRaw !== "" &&
1211
+ !/^https:\/\/.+/.test(webhookRaw)) {
1212
+ respond400("webhookUrl must be HTTPS");
1184
1213
  return;
1185
1214
  }
1215
+ // approvalGate
1186
1216
  const gateRaw = body.approvalGate;
1187
1217
  if (gateRaw !== undefined &&
1188
1218
  gateRaw !== "off" &&
1189
1219
  gateRaw !== "high" &&
1190
1220
  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
- }));
1221
+ respond400('approvalGate must be "off", "high", or "all"');
1195
1222
  return;
1196
1223
  }
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;
1224
+ // enableTimeOfDayAnomaly
1225
+ if (body.enableTimeOfDayAnomaly !== undefined &&
1226
+ typeof body.enableTimeOfDayAnomaly !== "boolean") {
1227
+ respond400("enableTimeOfDayAnomaly must be a boolean");
1228
+ return;
1225
1229
  }
1230
+ // driver
1226
1231
  const driverRaw = body.driver;
1227
1232
  if (driverRaw !== undefined) {
1228
1233
  const validDrivers = [
@@ -1236,26 +1241,11 @@ export class Server extends EventEmitter {
1236
1241
  "none",
1237
1242
  ];
1238
1243
  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
- }));
1244
+ respond400(`driver must be one of: ${validDrivers.join(", ")}`);
1256
1245
  return;
1257
1246
  }
1258
1247
  }
1248
+ // model
1259
1249
  if (body.model !== undefined) {
1260
1250
  const validModels = [
1261
1251
  "claude",
@@ -1265,40 +1255,137 @@ export class Server extends EventEmitter {
1265
1255
  "local",
1266
1256
  ];
1267
1257
  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
- }));
1258
+ respond400(`model must be one of: ${validModels.join(", ")}`);
1272
1259
  return;
1273
1260
  }
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
1261
  }
1262
+ // apiKey
1282
1263
  if (body.apiKey) {
1283
1264
  const { provider, key } = body.apiKey;
1284
1265
  const validProviders = ["anthropic", "openai", "google", "xai"];
1285
1266
  if (!validProviders.includes(provider) ||
1286
1267
  typeof key !== "string") {
1287
- res.writeHead(400, { "Content-Type": "application/json" });
1288
- res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
1268
+ respond400("Invalid apiKey provider or key");
1269
+ return;
1270
+ }
1271
+ }
1272
+ // pushServiceUrl
1273
+ const pushUrlTrimmed = body.pushServiceUrl !== undefined
1274
+ ? body.pushServiceUrl.trim()
1275
+ : undefined;
1276
+ if (pushUrlTrimmed !== undefined &&
1277
+ pushUrlTrimmed !== "" &&
1278
+ !pushUrlTrimmed.startsWith("https://")) {
1279
+ respond400("pushServiceUrl must be HTTPS");
1280
+ return;
1281
+ }
1282
+ // pushServiceBaseUrl — bridge callback origin embedded in SW
1283
+ // approveUrl/rejectUrl. http:// or attacker host = approval token
1284
+ // exfiltration. Validate before persisting.
1285
+ const pushBaseTrimmed = body.pushServiceBaseUrl !== undefined
1286
+ ? body.pushServiceBaseUrl.trim()
1287
+ : undefined;
1288
+ if (pushBaseTrimmed !== undefined &&
1289
+ pushBaseTrimmed !== "" &&
1290
+ !pushBaseTrimmed.startsWith("https://")) {
1291
+ respond400("pushServiceBaseUrl must be HTTPS");
1292
+ return;
1293
+ }
1294
+ // ntfyTopic — bearer-token on public ntfy.sh; charset-restricted.
1295
+ const ntfyTopicTrimmed = body.ntfyTopic !== undefined ? body.ntfyTopic.trim() : undefined;
1296
+ if (ntfyTopicTrimmed !== undefined &&
1297
+ ntfyTopicTrimmed !== "" &&
1298
+ !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopicTrimmed)) {
1299
+ respond400("ntfyTopic must match [A-Za-z0-9_-]{1,64}");
1300
+ return;
1301
+ }
1302
+ // ntfyServer — same threat model as pushServiceBaseUrl.
1303
+ const ntfyServerTrimmed = body.ntfyServer !== undefined
1304
+ ? body.ntfyServer.trim()
1305
+ : undefined;
1306
+ if (ntfyServerTrimmed !== undefined &&
1307
+ ntfyServerTrimmed !== "" &&
1308
+ !ntfyServerTrimmed.startsWith("https://")) {
1309
+ respond400("ntfyServer must be HTTPS");
1310
+ return;
1311
+ }
1312
+ // PHASE 2 — load config and apply all in-memory edits to `cfg`.
1313
+ // Still no disk writes; if anything throws here the request
1314
+ // returns 500 with no side effects.
1315
+ const configPath = patchworkConfigPath();
1316
+ const cfg = loadPatchworkConfig(configPath);
1317
+ cfg.dashboard = {
1318
+ port: cfg.dashboard?.port ?? 3200,
1319
+ requireApproval: cfg.dashboard?.requireApproval ?? ["high"],
1320
+ pushNotifications: cfg.dashboard?.pushNotifications ?? false,
1321
+ webhookUrl: hasWebhookUpdate
1322
+ ? webhookRaw || undefined
1323
+ : cfg.dashboard?.webhookUrl,
1324
+ };
1325
+ if (gateRaw !== undefined) {
1326
+ cfg.approvalGate = gateRaw;
1327
+ }
1328
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1329
+ cfg.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1330
+ }
1331
+ if (driverRaw !== undefined) {
1332
+ cfg.driver = driverRaw;
1333
+ }
1334
+ if (body.model !== undefined) {
1335
+ cfg.model = body.model;
1336
+ }
1337
+ // localEndpoint / localModel persist regardless of whether
1338
+ // `model` is sent. Previously they were silently dropped when
1339
+ // the dashboard updated only the endpoint of an already-local
1340
+ // install, which left the running driver pointed at the old
1341
+ // host until the user happened to re-pick "Local LLM" in the
1342
+ // UI.
1343
+ if (body.localEndpoint !== undefined) {
1344
+ cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1345
+ }
1346
+ if (body.localModel !== undefined) {
1347
+ cfg.localModel = body.localModel.trim() || undefined;
1348
+ }
1349
+ // Push / ntfy fields used to be set only on `this.*` and were
1350
+ // lost on bridge restart. Persist alongside the rest.
1351
+ if (pushUrlTrimmed !== undefined) {
1352
+ cfg.pushServiceUrl = pushUrlTrimmed || undefined;
1353
+ }
1354
+ if (body.pushServiceToken !== undefined) {
1355
+ cfg.pushServiceToken = body.pushServiceToken.trim() || undefined;
1356
+ }
1357
+ if (pushBaseTrimmed !== undefined) {
1358
+ cfg.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1359
+ }
1360
+ if (ntfyTopicTrimmed !== undefined) {
1361
+ cfg.ntfyTopic = ntfyTopicTrimmed || undefined;
1362
+ }
1363
+ if (ntfyServerTrimmed !== undefined) {
1364
+ cfg.ntfyServer = ntfyServerTrimmed || undefined;
1365
+ }
1366
+ // PHASE 3 — disk + secure-store writes. Order: secure store →
1367
+ // bridge driver config → patchwork config. Each rolls back what
1368
+ // it can on later failure (left to PR #02; for now log + 500).
1369
+ if (body.apiKey) {
1370
+ try {
1371
+ saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1372
+ }
1373
+ catch (writeErr) {
1374
+ this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1375
+ res.writeHead(500, { "Content-Type": "application/json" });
1376
+ res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1289
1377
  return;
1290
1378
  }
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.
1379
+ }
1380
+ if (driverRaw !== undefined) {
1294
1381
  try {
1295
- saveApiKeyToSecureStore(provider, key);
1382
+ saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
1296
1383
  }
1297
1384
  catch (writeErr) {
1298
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1385
+ this.logger.error(`[/config/patchwork] saveBridgeConfigDriver failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1299
1386
  res.writeHead(500, { "Content-Type": "application/json" });
1300
1387
  res.end(JSON.stringify({
1301
- error: "Failed to write provider API key",
1388
+ error: "Failed to write bridge driver config",
1302
1389
  }));
1303
1390
  return;
1304
1391
  }
@@ -1307,73 +1394,47 @@ export class Server extends EventEmitter {
1307
1394
  savePatchworkConfig(cfg, configPath);
1308
1395
  }
1309
1396
  catch (writeErr) {
1310
- this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? (writeErr.stack ?? writeErr.message) : String(writeErr)}`);
1397
+ this.logger.error(`[/config/patchwork] savePatchworkConfig failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`);
1311
1398
  res.writeHead(500, { "Content-Type": "application/json" });
1312
- res.end(JSON.stringify({
1313
- error: "Failed to write patchwork config",
1314
- }));
1399
+ res.end(JSON.stringify({ error: "Failed to write patchwork config" }));
1315
1400
  return;
1316
1401
  }
1402
+ // PHASE 4 — live state. Only after every persistence step
1403
+ // succeeded; ensures live state never diverges from disk.
1404
+ if (gateRaw !== undefined) {
1405
+ this.approvalGate = gateRaw;
1406
+ }
1407
+ if (body.enableTimeOfDayAnomaly !== undefined) {
1408
+ this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
1409
+ }
1317
1410
  if (hasWebhookUpdate) {
1318
- this.approvalWebhookUrl = raw || undefined;
1411
+ this.approvalWebhookUrl = webhookRaw || undefined;
1319
1412
  }
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;
1413
+ if (pushUrlTrimmed !== undefined) {
1414
+ this.pushServiceUrl = pushUrlTrimmed || undefined;
1328
1415
  }
1329
1416
  if (body.pushServiceToken !== undefined) {
1330
1417
  this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1331
1418
  }
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;
1419
+ if (pushBaseTrimmed !== undefined) {
1420
+ this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
1346
1421
  }
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;
1422
+ if (ntfyTopicTrimmed !== undefined) {
1423
+ this.ntfyTopic = ntfyTopicTrimmed || undefined;
1361
1424
  }
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;
1425
+ if (ntfyServerTrimmed !== undefined) {
1426
+ this.ntfyServer = ntfyServerTrimmed || undefined;
1373
1427
  }
1428
+ // restartRequired covers fields the bridge only reads at boot:
1429
+ // driver/model/apiKey (env injection + driver factory), plus
1430
+ // localEndpoint/localModel which LocalApiDriver reads at
1431
+ // construction time. Push/ntfy fields are live-mutated on
1432
+ // `this.*` in PHASE 4 below, so they do not require restart.
1374
1433
  const restartRequired = driverRaw !== undefined ||
1375
1434
  body.apiKey !== undefined ||
1376
- body.model !== undefined;
1435
+ body.model !== undefined ||
1436
+ body.localEndpoint !== undefined ||
1437
+ body.localModel !== undefined;
1377
1438
  res.writeHead(200, { "Content-Type": "application/json" });
1378
1439
  res.end(JSON.stringify({ ok: true, restartRequired }));
1379
1440
  }
@@ -1432,6 +1493,9 @@ export class Server extends EventEmitter {
1432
1493
  return;
1433
1494
  }
1434
1495
  const body = parsed.value ?? {};
1496
+ if (respondIfUnknownBodyKeys(res, body, ["engage", "reason"])) {
1497
+ return;
1498
+ }
1435
1499
  if (typeof body.engage !== "boolean") {
1436
1500
  res.writeHead(400, { "Content-Type": "application/json" });
1437
1501
  res.end(JSON.stringify({
@@ -1534,6 +1598,13 @@ export class Server extends EventEmitter {
1534
1598
  return;
1535
1599
  }
1536
1600
  const body = parsed.value ?? {};
1601
+ if (respondIfUnknownBodyKeys(res, body, [
1602
+ "crashReports",
1603
+ "usageStats",
1604
+ "localDiagnostics",
1605
+ ])) {
1606
+ return;
1607
+ }
1537
1608
  const update = {};
1538
1609
  if (typeof body.crashReports === "boolean") {
1539
1610
  update.crashReports = body.crashReports;