patchwork-os 0.2.0-beta.5.canary.8 → 0.2.0-beta.5.canary.80

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.
@@ -1279,6 +1297,20 @@ export class Server extends EventEmitter {
1279
1297
  respond400("pushServiceUrl must be HTTPS");
1280
1298
  return;
1281
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
+ }
1282
1314
  // pushServiceBaseUrl — bridge callback origin embedded in SW
1283
1315
  // approveUrl/rejectUrl. http:// or attacker host = approval token
1284
1316
  // exfiltration. Validate before persisting.
@@ -1309,6 +1341,41 @@ export class Server extends EventEmitter {
1309
1341
  respond400("ntfyServer must be HTTPS");
1310
1342
  return;
1311
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;
1362
+ try {
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
+ }
1312
1379
  // PHASE 2 — load config and apply all in-memory edits to `cfg`.
1313
1380
  // Still no disk writes; if anything throws here the request
1314
1381
  // returns 500 with no side effects.
@@ -1333,12 +1400,35 @@ export class Server extends EventEmitter {
1333
1400
  }
1334
1401
  if (body.model !== undefined) {
1335
1402
  cfg.model = body.model;
1336
- if (body.model === "local") {
1337
- if (body.localEndpoint !== undefined)
1338
- cfg.localEndpoint = body.localEndpoint.trim() || undefined;
1339
- if (body.localModel !== undefined)
1340
- cfg.localModel = body.localModel.trim() || undefined;
1341
- }
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;
1342
1432
  }
1343
1433
  // PHASE 3 — disk + secure-store writes. Order: secure store →
1344
1434
  // bridge driver config → patchwork config. Each rolls back what
@@ -1348,7 +1438,19 @@ export class Server extends EventEmitter {
1348
1438
  saveApiKeyToSecureStore(body.apiKey.provider, body.apiKey.key);
1349
1439
  }
1350
1440
  catch (writeErr) {
1351
- this.logger.error(`[/config/patchwork] saveApiKeyToSecureStore failed: ${writeErr instanceof Error ? 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}`);
1352
1454
  res.writeHead(500, { "Content-Type": "application/json" });
1353
1455
  res.end(JSON.stringify({ error: "Failed to write provider API key" }));
1354
1456
  return;
@@ -1379,7 +1481,20 @@ export class Server extends EventEmitter {
1379
1481
  // PHASE 4 — live state. Only after every persistence step
1380
1482
  // succeeded; ensures live state never diverges from disk.
1381
1483
  if (gateRaw !== undefined) {
1484
+ const prevGate = this.approvalGate;
1382
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
+ }
1383
1498
  }
1384
1499
  if (body.enableTimeOfDayAnomaly !== undefined) {
1385
1500
  this.enableTimeOfDayAnomaly = body.enableTimeOfDayAnomaly;
@@ -1390,8 +1505,8 @@ export class Server extends EventEmitter {
1390
1505
  if (pushUrlTrimmed !== undefined) {
1391
1506
  this.pushServiceUrl = pushUrlTrimmed || undefined;
1392
1507
  }
1393
- if (body.pushServiceToken !== undefined) {
1394
- this.pushServiceToken = body.pushServiceToken.trim() || undefined;
1508
+ if (pushTokenTrimmed !== undefined) {
1509
+ this.pushServiceToken = pushTokenTrimmed || undefined;
1395
1510
  }
1396
1511
  if (pushBaseTrimmed !== undefined) {
1397
1512
  this.pushServiceBaseUrl = pushBaseTrimmed || undefined;
@@ -1402,17 +1517,76 @@ export class Server extends EventEmitter {
1402
1517
  if (ntfyServerTrimmed !== undefined) {
1403
1518
  this.ntfyServer = ntfyServerTrimmed || undefined;
1404
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.
1405
1525
  const restartRequired = driverRaw !== undefined ||
1406
1526
  body.apiKey !== undefined ||
1407
- 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
+ }
1408
1576
  res.writeHead(200, { "Content-Type": "application/json" });
1409
1577
  res.end(JSON.stringify({ ok: true, restartRequired }));
1410
1578
  }
1411
1579
  }
1412
1580
  catch (err) {
1413
- this.logger.error(`[/config/patchwork] error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1414
- res.writeHead(400, { "Content-Type": "application/json" });
1415
- 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" }));
1416
1590
  }
1417
1591
  return;
1418
1592
  }
@@ -1541,8 +1715,10 @@ export class Server extends EventEmitter {
1541
1715
  }
1542
1716
  }
1543
1717
  // /telemetry-prefs — read/write per-flag telemetry preferences.
1544
- // GET → {crashReports, usageStats, localDiagnostics}
1545
- // 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).
1546
1722
  if (parsedUrl.pathname === "/telemetry-prefs") {
1547
1723
  if (req.method === "GET") {
1548
1724
  const prefs = getTelemetryPrefs();
@@ -1551,11 +1727,74 @@ export class Server extends EventEmitter {
1551
1727
  if (all?.lastSentAt !== undefined) {
1552
1728
  response.lastSentAt = all.lastSentAt;
1553
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
+ }
1554
1748
  res.writeHead(200, { "Content-Type": "application/json" });
1555
1749
  res.end(JSON.stringify(response));
1556
1750
  return;
1557
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
+ }
1558
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
+ }
1559
1798
  const TP_BODY_CAP = 1 * 1024;
1560
1799
  const parsed = await readJsonBody(req, TP_BODY_CAP);
1561
1800
  if (!parsed.ok) {
@@ -1576,6 +1815,25 @@ export class Server extends EventEmitter {
1576
1815
  return;
1577
1816
  }
1578
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
+ }
1579
1837
  if (typeof body.crashReports === "boolean") {
1580
1838
  update.crashReports = body.crashReports;
1581
1839
  }