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/README.md +1 -1
- package/dist/analyticsPrefs.d.ts +3 -0
- package/dist/analyticsPrefs.js +7 -0
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/approvalHttp.js +15 -0
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +9 -0
- package/dist/approvalQueue.js +17 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/bridge.js +9 -0
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/patchworkConfig.d.ts +18 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipes/chainedRunner.js +51 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +11 -1
- package/dist/recipes/yamlRunner.js +94 -5
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +2 -0
- package/dist/recipesHttp.js +20 -0
- package/dist/recipesHttp.js.map +1 -1
- package/dist/server.js +404 -133
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { unlinkSync } from "node:fs";
|
|
3
4
|
import http from "node:http";
|
|
4
5
|
import { WebSocket, WebSocketServer as WsServer } from "ws";
|
|
5
|
-
import {
|
|
6
|
+
import { clearAnalyticsConfig, getAnalyticsConfig } from "./analyticsConfig.js";
|
|
7
|
+
import { getAnalyticsPrefsAll, getAnalyticsPrefsPath, getAnalyticsSaltPath, getTelemetryPrefs, setTelemetryPrefs, } from "./analyticsPrefs.js";
|
|
6
8
|
import { handleApprovalsStream, routeApprovalRequest } from "./approvalHttp.js";
|
|
7
9
|
import { getApprovalQueue } from "./approvalQueue.js";
|
|
8
10
|
import { saveBridgeConfigDriver } from "./config.js";
|
|
9
11
|
import { tryHandleConnectorRoute, tryHandlePublicConnectorRoute, } from "./connectorRoutes.js";
|
|
10
12
|
import { timingSafeStringEqual } from "./crypto.js";
|
|
11
13
|
import { renderDashboardHtml } from "./dashboard.js";
|
|
14
|
+
import { isLoopbackOrPrivateEndpoint } from "./drivers/local/index.js";
|
|
12
15
|
import { EnvLockedFlagError, getEnvLockedValue, isEnvLockedFor, isWriteKillSwitchActive, KILL_SWITCH_WRITES, setFlag, } from "./featureFlags.js";
|
|
13
16
|
import { respondIfUnknownBodyKeys } from "./httpBodyValidation.js";
|
|
14
17
|
import { respond500 } from "./httpErrorResponse.js";
|
|
@@ -1158,6 +1161,21 @@ export class Server extends EventEmitter {
|
|
|
1158
1161
|
return;
|
|
1159
1162
|
}
|
|
1160
1163
|
if (parsedUrl.pathname === "/settings" && req.method === "POST") {
|
|
1164
|
+
// Kill-switch gate: during an incident a settings mutation can
|
|
1165
|
+
// defeat the panic posture (e.g. switching driver to one with a
|
|
1166
|
+
// leaked API key, flipping approvalGate to "off"). The /settings
|
|
1167
|
+
// card on the dashboard sits directly above the kill-switch
|
|
1168
|
+
// toggle — users will reasonably read "writes blocked" as
|
|
1169
|
+
// covering both. Refuse with 423 Locked; the /kill-switch
|
|
1170
|
+
// endpoint itself is the only way out and is not gated.
|
|
1171
|
+
if (isWriteKillSwitchActive()) {
|
|
1172
|
+
res.writeHead(423, { "Content-Type": "application/json" });
|
|
1173
|
+
res.end(JSON.stringify({
|
|
1174
|
+
error: "kill_switch_blocked",
|
|
1175
|
+
reason: "Settings writes are disabled while the write kill-switch is engaged. Release it via POST /kill-switch to mutate config.",
|
|
1176
|
+
}));
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1161
1179
|
// 16 KB — settings POSTs are short-string fields (URLs, API keys,
|
|
1162
1180
|
// gate level). 16 KB is generous; an authenticated attacker can't
|
|
1163
1181
|
// stream gigabytes here.
|
|
@@ -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
|
|
1224
|
+
const webhookRaw = hasWebhookUpdate
|
|
1197
1225
|
? (body.webhookUrl?.trim() ?? "")
|
|
1198
1226
|
: undefined;
|
|
1199
|
-
if (
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
res.end(JSON.stringify({ error: "Invalid apiKey provider or key" }));
|
|
1286
|
+
respond400("Invalid apiKey provider or key");
|
|
1307
1287
|
return;
|
|
1308
1288
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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 =
|
|
1503
|
+
this.approvalWebhookUrl = webhookRaw || undefined;
|
|
1337
1504
|
}
|
|
1338
|
-
if (
|
|
1339
|
-
|
|
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 (
|
|
1348
|
-
this.pushServiceToken =
|
|
1508
|
+
if (pushTokenTrimmed !== undefined) {
|
|
1509
|
+
this.pushServiceToken = pushTokenTrimmed || undefined;
|
|
1349
1510
|
}
|
|
1350
|
-
if (
|
|
1351
|
-
|
|
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 (
|
|
1366
|
-
|
|
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 (
|
|
1381
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
1532
|
-
// POST
|
|
1718
|
+
// GET → {crashReports, usageStats, localDiagnostics, endpoint, endpointSource}
|
|
1719
|
+
// POST {crashReports?, usageStats?, localDiagnostics?} → same shape (partial update)
|
|
1720
|
+
// DELETE → clears prefs file + analytics-config + install salt
|
|
1721
|
+
// (right-to-erasure affordance).
|
|
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
|
}
|