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/README.md +2 -0
- 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/connectors/googleCalendar.js +1 -1
- package/dist/connectors/googleCalendar.js.map +1 -1
- package/dist/httpBodyValidation.d.ts +41 -0
- package/dist/httpBodyValidation.js +45 -0
- package/dist/httpBodyValidation.js.map +1 -0
- package/dist/patchworkConfig.d.ts +26 -1
- package/dist/patchworkConfig.js +15 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipeRoutes.js +16 -6
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/server.js +332 -128
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
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
|
|
1222
|
+
const webhookRaw = hasWebhookUpdate
|
|
1179
1223
|
? (body.webhookUrl?.trim() ?? "")
|
|
1180
1224
|
: undefined;
|
|
1181
|
-
if (
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
1293
|
-
// ~/.patchwork/config.json. Empty string clears.
|
|
1430
|
+
}
|
|
1431
|
+
if (driverRaw !== undefined) {
|
|
1294
1432
|
try {
|
|
1295
|
-
|
|
1433
|
+
saveBridgeConfigDriver(driverRaw, this.bridgeConfigPath);
|
|
1296
1434
|
}
|
|
1297
1435
|
catch (writeErr) {
|
|
1298
|
-
this.logger.error(`[/config/patchwork]
|
|
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
|
|
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 ?
|
|
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 =
|
|
1462
|
+
this.approvalWebhookUrl = webhookRaw || undefined;
|
|
1319
1463
|
}
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
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 (
|
|
1333
|
-
|
|
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 (
|
|
1348
|
-
|
|
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 (
|
|
1363
|
-
|
|
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
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
}
|