dsh-connect-workbuddy 5.8.4 → 5.9.0

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.
@@ -1467,8 +1467,27 @@ function text(value) {
1467
1467
  *
1468
1468
  * Any other malformed value is left to the caller to drop — a wrong window
1469
1469
  * would hide a live promotion, and guessing is not this parser's job.
1470
+ *
1471
+ * THE TYPE GUARD IS LOAD-BEARING, NOT DEFENSIVE DECORATION (issue #139). The
1472
+ * upstream feeds a string, and `parseDaily`'s own `text()` would already have
1473
+ * dropped a number — but the SAVED `lastCatalog` is a second, independent
1474
+ * entrance to this function, and nothing on that path type-checks. A promotion
1475
+ * object rides through `toPersistedWorkBuddyModel` untouched (the settings
1476
+ * schema keeps unknown keys), survives `resolveRoster`, and reaches the
1477
+ * renderer's `selectPromoBadge`. A numeric `start` there used to throw
1478
+ * `value.trim is not a function` DURING RENDER, which the host's slot error
1479
+ * boundary turned into a blank configuration section — the page looked as if
1480
+ * the plugin had never installed.
1481
+ *
1482
+ * Refusing a non-string is the same answer the parser already gives every
1483
+ * other unreadable shape: drop the window rather than guess. Interpreting a
1484
+ * number as "minutes since midnight" was considered and rejected — `1380`
1485
+ * happens to mean 23:00, but no upstream has ever published that shape, and
1486
+ * inventing hours for a promotion the upstream never described is exactly the
1487
+ * overstatement this module exists to prevent.
1470
1488
  */
1471
1489
  function minutesOfClock(value) {
1490
+ if (typeof value !== "string") return void 0;
1472
1491
  const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
1473
1492
  if (match === null) return void 0;
1474
1493
  const hours = Number(match[1]);
@@ -1528,8 +1547,27 @@ function parseHover(value) {
1528
1547
  ...textEn === void 0 ? {} : { textEn }
1529
1548
  };
1530
1549
  }
1531
- /** The upstream's `schedule.daily` → the windows this plugin can evaluate. */
1532
- function parseDaily(value) {
1550
+ /**
1551
+ * What an unreadable endpoint actually was, for the one diagnostic line below.
1552
+ *
1553
+ * Deliberately coarse: a NUMBER is the shape a future upstream format change
1554
+ * would plausibly take (minutes since midnight), so it is named separately
1555
+ * from "some other non-string". Reporting the typeof is enough to tell the two
1556
+ * apart in a log without ever echoing upstream content into the console.
1557
+ */
1558
+ function shapeOf(value) {
1559
+ if (typeof value === "number") return "number";
1560
+ if (value === null) return "null";
1561
+ if (Array.isArray(value)) return "array";
1562
+ return typeof value;
1563
+ }
1564
+ /**
1565
+ * The upstream's `schedule.daily` → the windows this plugin can evaluate.
1566
+ *
1567
+ * `label` names the entry for the diagnostic line and is passed in rather than
1568
+ * read from `value`: the entry id lives one level up, in `parsePromo`.
1569
+ */
1570
+ function parseDaily(value, label) {
1533
1571
  if (!Array.isArray(value)) return void 0;
1534
1572
  const windows = [];
1535
1573
  for (const entry of value) {
@@ -1537,8 +1575,12 @@ function parseDaily(value) {
1537
1575
  if (raw === void 0) continue;
1538
1576
  const start = text(raw["start"]);
1539
1577
  const end = text(raw["end"]);
1540
- if (start === void 0 || end === void 0) continue;
1541
- if (minutesOfClock(start) === void 0 || minutesOfClock(end) === void 0) continue;
1578
+ if (start === void 0 || end === void 0 || minutesOfClock(start) === void 0 || minutesOfClock(end) === void 0) {
1579
+ try {
1580
+ console.warn(`[dsh-connect-workbuddy] promotion "${label}": dropped an unreadable daily window (start: ${shapeOf(raw["start"])}, end: ${shapeOf(raw["end"])}); the upstream may have changed the time format`);
1581
+ } catch {}
1582
+ continue;
1583
+ }
1542
1584
  windows.push({
1543
1585
  start,
1544
1586
  end
@@ -1578,13 +1620,13 @@ function parseDiscount(value) {
1578
1620
  };
1579
1621
  }
1580
1622
  /** The upstream's `schedule` → this plugin's schedule shape. */
1581
- function parseSchedule(value) {
1623
+ function parseSchedule(value, label) {
1582
1624
  const raw = record(value);
1583
1625
  if (raw === void 0) return void 0;
1584
1626
  const timezone = text(raw["timezone"]);
1585
1627
  const validFrom = parseInstant(raw["validFrom"]);
1586
1628
  const validUntil = parseInstant(raw["validUntil"]);
1587
- const daily = parseDaily(raw["daily"]);
1629
+ const daily = parseDaily(raw["daily"], label);
1588
1630
  const dailyUnusable = daily === void 0 && Array.isArray(raw["daily"]) && raw["daily"].length > 0;
1589
1631
  if (timezone === void 0 && validFrom === void 0 && validUntil === void 0 && daily === void 0 && !dailyUnusable) return void 0;
1590
1632
  return {
@@ -1605,11 +1647,12 @@ function parsePromo(value, source) {
1605
1647
  const badge = parseBadge(raw["badge"]);
1606
1648
  if (badge === void 0) return void 0;
1607
1649
  const hover = parseHover(raw["hover"]);
1608
- const schedule = parseSchedule(raw["schedule"]);
1650
+ const id = text(raw["id"]) ?? `${source}:${modelIds.join(",")}`;
1651
+ const schedule = parseSchedule(raw["schedule"], id);
1609
1652
  const discount = parseDiscount(raw["discount"]);
1610
1653
  const priority = typeof raw["priority"] === "number" && Number.isFinite(raw["priority"]) ? raw["priority"] : 0;
1611
1654
  return {
1612
- id: text(raw["id"]) ?? `${source}:${modelIds.join(",")}`,
1655
+ id,
1613
1656
  source,
1614
1657
  modelIds,
1615
1658
  badge,
@@ -6940,7 +6983,7 @@ function describeRosterSeenPayload(raw) {
6940
6983
  //#endregion
6941
6984
  //#region src/version.ts
6942
6985
  /** The npm package version this build was produced from. */
6943
- const WORKBUDDY_CONNECT_VERSION = "5.8.4";
6986
+ const WORKBUDDY_CONNECT_VERSION = "5.9.0";
6944
6987
  //#endregion
6945
6988
  //#region src/host-heartbeat.ts
6946
6989
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-CAX0oKQT.js";
1
+ import { $ as parseCreditMultiplier, A as isEncryptedCredentialError, At as isEncryptedFieldWrapper, B as WorkBuddyCredentialRejectedError, Bt as writeFileAtomic, C as WorkBuddyCredentialStore, Ct as WORKBUDDY_APP_EXECUTABLE_ENV, D as defaultDesktopAuthDirs, Dt as fetchAtRestKeyPayload, E as defaultDesktopAuthCandidates, Et as deriveAtRestKeyId, F as workbuddyAccountId, Ft as readAtRestKey, G as forceNeutralHead, H as channelGateMemberOf, I as workbuddyOwnAuthPath, It as workbuddyAppExecutableCandidates, J as isCredentialRejectedError, K as isContextTooLongBody, L as CONTEXT_OVERFLOW_MARKER, M as legacyWorkbuddyOwnAuthPath, Mt as macosBundleExecutable, N as parseWorkBuddyAuth, Nt as macosNestedAppBundles, O as defaultDesktopAuthPath, Ot as findWorkbuddyAppExecutable, P as preferredAccountName, Pt as openEncryptedField, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as resolveDshHome, S as WORKBUDDY_AUTH_FILE_ENV, St as nativeModalityOf, T as authFileName, Tt as deriveAtRestKey, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, W as declaredTools, X as knownRegionOf, Y as isFirstMessageGateBody, Z as modelIdOfBody, _ as deriveCatalog, _t as pickImageModel, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as NATIVE_MODALITY_BY_MODEL_ID, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as WorkBuddyImageError, h as WorkBuddyCatalog, ht as DEFAULT_IMAGE_SIZE, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as isWorkbuddyBundle, k as hasEncryptedCredentialFields, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseQueueAnswer, w as WorkBuddyEncryptedCredentialError, wt as clearAtRestKeyCache, x as WORKBUDDY_AUTH_FILENAME, xt as imageDefaultFor, y as withoutRouters, yt as queueNoticeText, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as withFileLock } from "./host-heartbeat-C1tVU2IN.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -13,6 +13,7 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { createServer } from "node:http";
15
15
  import { Readable } from "node:stream";
16
+ import { isIP } from "node:net";
16
17
  //#region src/catalog-sync.ts
17
18
  /**
18
19
  * The saved model directory (`lastCatalog`) and the LIVE upstream roster.
@@ -84,6 +85,10 @@ import { Readable } from "node:stream";
84
85
  * `supportsImages` ride along for the same reason: the upstream publishes
85
86
  * them, the saved copy is a snapshot of one moment, and a stale multiplier is
86
87
  * a wrong PRICE shown to the user.
88
+ *
89
+ * `promos` is NOT in this list — see {@link overlayLiveCapabilities} for why it
90
+ * needs a different rule (it must be able to CLEAR a saved value, which this
91
+ * loop cannot express).
87
92
  */
88
93
  const LIVE_OWNED_FIELDS = [
89
94
  "reasoning",
@@ -109,6 +114,31 @@ const LIVE_OWNED_FIELDS = [
109
114
  * and the user's effective choice is stamped over it by `withEffectiveOff`
110
115
  * afterwards. So the overlay restores the declaration, and the user's tick
111
116
  * still wins.
117
+ *
118
+ * `promos` IS LIVE-OWNED TOO, AND IS REPLACED UNCONDITIONALLY. It cannot use
119
+ * the loop above, because that loop only writes a field the live entry HAS —
120
+ * which would keep a saved promotion forever once the upstream withdrew it.
121
+ * Both directions were observed to be wrong before this rule existed:
122
+ *
123
+ * - saved WITHOUT `promos`, live WITH them → the live promotions were
124
+ * dropped entirely, so a model on a night discount drew no badge at all;
125
+ * - saved WITH old `promos`, live with new ones → the saved ones won, so a
126
+ * promotion whose hours the upstream had since changed kept advertising
127
+ * the OLD hours.
128
+ *
129
+ * The card persists `promos` into `lastCatalog` (the settings schema keeps
130
+ * unknown keys), so a user who saved the directory once had their badges
131
+ * frozen at that moment — the exact opposite of what `src/promo.ts` promises
132
+ * ("a badge is a function of the clock, not of the fetch"). The upstream's
133
+ * current answer therefore REPLACES the saved one, including when that answer
134
+ * is "no promotions at all": a badge the upstream no longer publishes must
135
+ * stop being drawn, and a standing entry with no schedule has no absolute
136
+ * window to expire it on its own.
137
+ *
138
+ * The key is DELETED rather than set to `undefined` when the live entry has
139
+ * no promotions, for the same reason `resolveRoster` deletes `busyLevel`: an
140
+ * explicit `undefined` survives `structuredClone` and the settings write
141
+ * path's strict JSON codec rejects the whole save.
112
142
  */
113
143
  function overlayLiveCapabilities(saved, live) {
114
144
  if (live === void 0) return saved;
@@ -117,9 +147,20 @@ function overlayLiveCapabilities(saved, live) {
117
147
  const value = live[field];
118
148
  if (value !== void 0) overlay[field] = value;
119
149
  }
120
- if (Object.keys(overlay).length === 0) return saved;
150
+ const livePromos = live.promos;
151
+ const savedPromos = saved.promos;
152
+ const promosDiffer = livePromos !== savedPromos && (livePromos !== void 0 || savedPromos !== void 0);
153
+ if (Object.keys(overlay).length === 0 && !promosDiffer) return saved;
154
+ if (livePromos !== void 0) {
155
+ overlay["promos"] = livePromos;
156
+ return {
157
+ ...saved,
158
+ ...overlay
159
+ };
160
+ }
161
+ const { promos: _stalePromos, ...rest } = saved;
121
162
  return {
122
- ...saved,
163
+ ...rest,
123
164
  ...overlay
124
165
  };
125
166
  }
@@ -2320,6 +2361,203 @@ function attachUsage(directory, report, onError) {
2320
2361
  })();
2321
2362
  };
2322
2363
  }
2364
+ //#endregion
2365
+ //#region src/loopback.ts
2366
+ /**
2367
+ * Who is allowed to talk to the plugin's own HTTP surfaces.
2368
+ *
2369
+ * There are two distinct questions and they stop two different attacks, so both
2370
+ * live here rather than being re-derived per route. A previous version kept the
2371
+ * Host check inside `shim.ts` and only the Origin check inside `web-status.ts`,
2372
+ * which meant the shim's chat route had both guards while the settings card's
2373
+ * thirteen routes (ten POST + three GET) had one — the asymmetry was invisible
2374
+ * because each file looked internally consistent.
2375
+ *
2376
+ * - **Host** answers "is this request addressed to the interface it landed on?".
2377
+ * A DNS-rebinding page sends requests whose `Host` is the *attacker's* domain
2378
+ * (that is the whole trick: the browser resolves the attacker name to
2379
+ * 127.0.0.1), so requiring the Host to match the arrival interface drops it.
2380
+ * This is the guard an Origin-only check does **not** provide.
2381
+ * - **Origin** answers "did a page on a foreign origin cause this?". A
2382
+ * non-browser client sends no `Origin` at all and must pass, or the plugin's
2383
+ * own `fetch` calls would be refused.
2384
+ *
2385
+ * Neither is authentication: any local process can forge both headers. They are
2386
+ * the minimum for these surfaces, not a substitute for a secret when a route
2387
+ * spends credit.
2388
+ *
2389
+ * ## Two callers, two strictnesses (deliberate)
2390
+ *
2391
+ * - `src/web-status.ts` serves the settings card over the HOST's webserver, which
2392
+ * the harness may configure to bind a concrete non-loopback address. It uses
2393
+ * `hostMatchesListener` / `originIsLoopback(origin, localAddress)`, i.e. "is
2394
+ * this addressed to / coming from the interface the connection landed on".
2395
+ * - `src/shim.ts` binds `listen(0, '127.0.0.1')` itself, so no LAN request can
2396
+ * ever reach it. It uses the stricter `hostIsLoopback`, which is not a
2397
+ * different notion of "local" but the same one narrowed to the only case that
2398
+ * can occur.
2399
+ *
2400
+ * Keep that in mind before "unifying" the two: the strict form is not a bug in
2401
+ * the shim, and the loose form is not laxness in the card.
2402
+ *
2403
+ * @module dsh-connect-workbuddy/loopback
2404
+ */
2405
+ /**
2406
+ * Hostnames that count as this machine without comparing against a bind address.
2407
+ *
2408
+ * `localhost` is here because it names this machine by definition. The IPv4
2409
+ * entries cover the loopback /8 rather than the single address 127.0.0.1: the
2410
+ * host harness itself treats the whole 127/8 as loopback (its own
2411
+ * `isLoopbackHostname` accepts it, and 127.0.0.2 is a usable second loopback
2412
+ * address on Linux), so refusing 127.0.0.2 here would reject a request the
2413
+ * host considers local.
2414
+ */
2415
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "[::1]"]);
2416
+ /**
2417
+ * Whether a string is a bare IP literal (IPv4 dotted quad, or IPv6 with or
2418
+ * without the brackets a Host header puts around it).
2419
+ *
2420
+ * Deliberately narrow: this decides whether a value is an ADDRESS that may be
2421
+ * compared with a Host header, so it must reject hostnames and host:port forms.
2422
+ * A loose "looks like hex" check would label `[deadbeef]` an address, and while
2423
+ * that cannot open the gate on its own (both sides must still be equal), a
2424
+ * predicate that answers "yes" to a non-address erodes the property the check is
2425
+ * supposed to have. `node:net`'s own parser is the authority for IPv6.
2426
+ */
2427
+ function isIpLiteral(value) {
2428
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(value)) return value.split(".").every((part) => Number(part) <= 255 && !(part.length > 1 && part.startsWith("0")));
2429
+ const inner = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
2430
+ return isIP(inner) === 6;
2431
+ }
2432
+ /** Whether a bare hostname is inside IPv4 loopback space (127.0.0.0/8). */
2433
+ function isIpv4Loopback(hostname) {
2434
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname);
2435
+ if (match === null) return false;
2436
+ const octets = match.slice(1, 5).map((part) => Number(part));
2437
+ if (octets.some((octet) => octet > 255)) return false;
2438
+ if (match.slice(1, 5).some((part) => part.length > 1 && part.startsWith("0"))) return false;
2439
+ return octets[0] === 127;
2440
+ }
2441
+ /**
2442
+ * Strip the optional `:port` from a Host header value, IPv6-bracket aware.
2443
+ *
2444
+ * The bracketed form is returned WITH brackets, because {@link LOOPBACK_HOSTS}
2445
+ * stores it that way and the two must agree.
2446
+ *
2447
+ * A `]` that is not at the end of the value (or followed only by `:<digits>`)
2448
+ * makes the whole thing unparseable, and unparseable returns the input
2449
+ * unchanged so no caller can mistake it for a loopback name. That matters: a
2450
+ * naive `slice(0, end + 1)` turns `[::1]evil.example` into `[::1]` and reads a
2451
+ * foreign host as this machine. Browsers never send such a value, but this
2452
+ * function is a security boundary, so "never" is the wrong bet.
2453
+ */
2454
+ function hostnameOfHost(host) {
2455
+ let hostname = host.trim().toLowerCase();
2456
+ if (hostname.startsWith("[")) {
2457
+ const end = hostname.indexOf("]");
2458
+ if (end === -1) return hostname;
2459
+ const after = hostname.slice(end + 1);
2460
+ if (after !== "" && !/^:\d+$/.test(after)) return hostname;
2461
+ return hostname.slice(0, end + 1);
2462
+ }
2463
+ const colon = hostname.lastIndexOf(":");
2464
+ if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
2465
+ return hostname;
2466
+ }
2467
+ /**
2468
+ * Whether a Host header names the loopback interface.
2469
+ *
2470
+ * An absent Host is treated as NOT loopback. HTTP/1.1 requires the header, so
2471
+ * its absence is either a malformed request or an attempt to dodge the check —
2472
+ * neither deserves the benefit of the doubt on a surface that can spend credit.
2473
+ */
2474
+ function hostIsLoopback(host) {
2475
+ if (host === void 0 || host.trim() === "") return false;
2476
+ return isLocalHostname(hostnameOfHost(host));
2477
+ }
2478
+ /**
2479
+ * Whether a parsed hostname names this machine.
2480
+ *
2481
+ * The single definition both predicates use, so the Host and Origin paths cannot
2482
+ * disagree about what "local" means — they did before (`127.0.0.2` and a trailing
2483
+ * dot were treated differently by each), which is exactly the kind of split this
2484
+ * module exists to prevent.
2485
+ */
2486
+ function isLocalHostname(hostname) {
2487
+ const name = hostname.endsWith(".") && !hostname.endsWith("..") ? hostname.slice(0, -1) : hostname;
2488
+ return LOOPBACK_HOSTS.has(name) || name === "::1" || isIpv4Loopback(name);
2489
+ }
2490
+ /**
2491
+ * Whether a Host header names the address this connection actually arrived on.
2492
+ *
2493
+ * This is the guard the routes use, and "must be loopback" is the wrong question.
2494
+ * The host harness does NOT allow wildcard binds in the version this plugin runs
2495
+ * against (0.2.x rejects `0.0.0.0` outright: "bind one concrete IPv4 or IPv6
2496
+ * address of a local interface instead"), but it DOES allow binding one concrete
2497
+ * local address — including a LAN address. A user who binds their LAN IP and
2498
+ * opens the GUI at `http://192.168.1.5:3000` sends exactly that in Host, and the
2499
+ * harness itself considers such a request trusted (its own trust layer accepts
2500
+ * "the listener's bind IP literal on any port"). A loopback-only check would 403
2501
+ * that user's read routes while the rest of the harness works, which is a real
2502
+ * regression for a supported configuration.
2503
+ *
2504
+ * So the question is "was this request addressed to the socket it landed on?".
2505
+ * `localAddress` answers that: it is the interface the CONNECTION was accepted
2506
+ * on, not the bind literal. Comparing the two drops a rebinding request — whose
2507
+ * Host is the attacker's domain while the socket landed on 127.0.0.1 or a LAN
2508
+ * address — while accepting every genuine one, loopback or LAN.
2509
+ *
2510
+ * The local branch is not merely an optimisation: a loopback-bound listener
2511
+ * reports `127.0.0.1` from `localAddress`, so the comparison alone would accept
2512
+ * `Host: 127.0.0.1` anyway. It is stated first so the answer does not depend on
2513
+ * how the platform spells its own loopback, and so an undefined `localAddress`
2514
+ * (a proxy, a test double) still admits genuine local traffic.
2515
+ */
2516
+ function hostMatchesListener(host, localAddress) {
2517
+ if (host === void 0 || host.trim() === "") return false;
2518
+ const hostname = hostnameOfHost(host);
2519
+ if (isLocalHostname(hostname)) return true;
2520
+ if (hostname === "0.0.0.0" || hostname === "[::]") return false;
2521
+ if (localAddress === void 0 || localAddress.trim() === "") return false;
2522
+ const local = localAddress.trim().toLowerCase();
2523
+ if (!isIpLiteral(local) && !isIpLiteral(`[${local}]`)) return false;
2524
+ const bare = (value) => value.startsWith("[") && value.endsWith("]") && isIP(value.slice(1, -1)) === 6 ? value.slice(1, -1) : value;
2525
+ return bare(hostname) === bare(local);
2526
+ }
2527
+ /**
2528
+ * Whether a browser-sent Origin may drive this route.
2529
+ *
2530
+ * An ABSENT Origin passes: that is the shape of a non-browser client (the
2531
+ * plugin's own fetch, a curl, the tests), and refusing it would break the
2532
+ * plugin's own calls.
2533
+ *
2534
+ * A PRESENT Origin must name this machine — judged by the SAME rule as the Host
2535
+ * header, which is why this takes `localAddress` too. Leaving it loopback-only
2536
+ * while Host accepted any local interface was a real split: a LAN browser's
2537
+ * same-origin POST carries `Origin: http://192.168.1.5:3000`, so the Host gate
2538
+ * admitted it and this one then refused all ten write routes. The two predicates
2539
+ * use one definition now (see `isLocalHostname`), because a browser reaching the
2540
+ * server on its LAN address is exactly as legitimate as one reaching it on
2541
+ * loopback — the harness trusts the bind literal on any port either way.
2542
+ *
2543
+ * The comparison is over the HOSTNAME only. `localAddress` carries no port, and
2544
+ * the route's own port is what a same-origin page must already be using, so a
2545
+ * port comparison here would add nothing: a page on the same host but another
2546
+ * port is not a threat this gate is meant to stop (it would need code already
2547
+ * running on this machine), and treating it as one would break legitimate
2548
+ * setups behind a proxy that terminates on a different port than it forwards to.
2549
+ * That intentional limit is recorded rather than silently assumed.
2550
+ */
2551
+ function originIsLoopback(origin, localAddress) {
2552
+ if (origin === void 0 || origin.trim() === "") return true;
2553
+ try {
2554
+ const { hostname } = new URL(origin);
2555
+ if (isLocalHostname(hostname)) return true;
2556
+ return hostMatchesListener(hostname, localAddress);
2557
+ } catch {
2558
+ return false;
2559
+ }
2560
+ }
2323
2561
  /** One or two full-width bars — the count is a spelling variable. */
2324
2562
  const BARS = `||?`;
2325
2563
  new RegExp(`${BARS}DSML${BARS}`, "g");
@@ -3229,45 +3467,6 @@ const REQUEST_BODY_LIMIT = 67108864;
3229
3467
  * rate-limiting.
3230
3468
  */
3231
3469
  const FAILOVER_ACCOUNT_GAP_MS = 2e3;
3232
- /** Loopback hostnames the shim's own in-process client uses. */
3233
- const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
3234
- "127.0.0.1",
3235
- "localhost",
3236
- "[::1]"
3237
- ]);
3238
- /** Strip the optional :port from a Host header value, IPv6-bracket aware. */
3239
- function hostnameOfHost(host) {
3240
- let hostname = host.trim().toLowerCase();
3241
- if (hostname.startsWith("[")) {
3242
- const end = hostname.indexOf("]");
3243
- return end === -1 ? hostname : hostname.slice(0, end + 1);
3244
- }
3245
- const colon = hostname.lastIndexOf(":");
3246
- if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
3247
- return hostname;
3248
- }
3249
- /**
3250
- * The request's Host header must name the loopback interface. A DNS-rebinding
3251
- * page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
3252
- * Host, so this check drops those before any routing happens.
3253
- */
3254
- function hostIsLoopback(host) {
3255
- if (host === void 0 || host.trim() === "") return false;
3256
- return LOOPBACK_HOSTS.has(hostnameOfHost(host));
3257
- }
3258
- /**
3259
- * A browser-sent Origin (present header) must be loopback. Non-browser
3260
- * clients (the plugin's own fetch calls) send no Origin at all and pass.
3261
- */
3262
- function originIsLoopback(origin) {
3263
- if (origin === void 0 || origin.trim() === "") return true;
3264
- try {
3265
- const { hostname } = new URL(origin);
3266
- return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
3267
- } catch {
3268
- return false;
3269
- }
3270
- }
3271
3470
  /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
3272
3471
  function isJsonContentType(req) {
3273
3472
  const type = req.headers["content-type"];
@@ -7281,21 +7480,10 @@ function tpsProbeRequestBody(modelId) {
7281
7480
  content: "List the numbers from 1 to 400 in words, separated by single spaces."
7282
7481
  }],
7283
7482
  max_tokens: 256,
7284
- ...OFF_REASONING_FIELDS,
7285
7483
  stream: true
7286
7484
  });
7287
7485
  }
7288
7486
  /**
7289
- * Ask for the lowest reasoning effort available.
7290
- *
7291
- * A reasoning model asked to count spends its `max_tokens` inside the reasoning
7292
- * channel; the stream then carries thinking deltas and little or no content, and
7293
- * timing it would measure the wrong thing. `none` is the widest supported
7294
- * spelling; gateways that do not know it ignore the field (measured: the CN
7295
- * gateway answers 200 with it).
7296
- */
7297
- const OFF_REASONING_FIELDS = { reasoning_effort: "none" };
7298
- /**
7299
7487
  * Read one SSE stream INCREMENTALLY, timing each content deltas arrival.
7300
7488
  *
7301
7489
  * The incremental read is the measurement. `response.text()` buffers the whole
@@ -8700,16 +8888,46 @@ function readJsonBody(req) {
8700
8888
  req.on("error", reject);
8701
8889
  });
8702
8890
  }
8703
- /** Loopback browser origins only; other devices are refused until trusted origins exist. */
8704
- function loopbackOrigin(req) {
8705
- const origin = req.headers.origin;
8706
- if (origin === void 0) return true;
8707
- try {
8708
- const { hostname } = new URL(origin);
8709
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
8710
- } catch {
8891
+ /**
8892
+ * The full gate a state-changing route must clear: Host AND Origin.
8893
+ *
8894
+ * Origin alone is not enough. It answers "did a foreign page cause this?", but a
8895
+ * DNS-rebinding attack arrives with the attacker's domain in `Host` and no
8896
+ * useful Origin, so the Host check is what drops it — and until this function
8897
+ * existed, all thirteen routes below (ten POST, three GET) had only the Origin
8898
+ * half while the shim's chat route had both. The asymmetry was invisible per-file.
8899
+ *
8900
+ * Both halves are cheap and NEITHER IS AUTHENTICATION. A local process — or any
8901
+ * peer that can reach the listener when it is bound to a LAN address — may send
8902
+ * whatever Host and Origin it likes. What these two checks buy is narrow and
8903
+ * worth stating precisely:
8904
+ *
8905
+ * - they stop a BROWSER on a foreign page from driving these routes (the browser
8906
+ * chooses Origin for us, and rebinding fails the Host comparison), which is
8907
+ * the CSRF / rebinding family;
8908
+ * - they do NOT stop a program that can already open a socket to the port.
8909
+ *
8910
+ * There is no per-route secret here: `req.headers` is read for `host` and
8911
+ * `origin` and nothing else. Every route that spends credit or returns token
8912
+ * material is protected by the same two forgeable headers as the read routes, so
8913
+ * binding this listener to a non-loopback address exposes those routes to the
8914
+ * local network. That is a property of the whole surface, not of this function,
8915
+ * and it must not be described as though a stronger guard existed.
8916
+ *
8917
+ * Absent Host is refused: HTTP/1.1 requires it, and its absence on a
8918
+ * credit-spending surface is not worth the benefit of the doubt. Absent Origin
8919
+ * passes, because that is the shape of the plugin's own fetch and of curl.
8920
+ */
8921
+ function requireLoopback(req, res) {
8922
+ if (!hostMatchesListener(req.headers.host, req.socket?.localAddress)) {
8923
+ json(res, 403, { error: "host-not-trusted" });
8711
8924
  return false;
8712
8925
  }
8926
+ if (!originIsLoopback(req.headers.origin, req.socket?.localAddress)) {
8927
+ json(res, 403, { error: "origin-not-trusted" });
8928
+ return false;
8929
+ }
8930
+ return true;
8713
8931
  }
8714
8932
  /** Map the credit answer to the card's compact document. */
8715
8933
  function toCredits(answer) {
@@ -9126,10 +9344,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9126
9344
  json(res, 405, { error: "method not allowed" });
9127
9345
  return;
9128
9346
  }
9129
- if (!loopbackOrigin(req)) {
9130
- json(res, 403, { error: "origin-not-trusted" });
9131
- return;
9132
- }
9347
+ if (!requireLoopback(req, res)) return;
9133
9348
  const region = requestRegion(req, res);
9134
9349
  if (region === void 0) return;
9135
9350
  const detail = fastDetailOf(req.url ?? "") ? "fast" : "full";
@@ -9145,7 +9360,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9145
9360
  path: WORKBUDDY_ACCOUNT_CREDITS_PATH,
9146
9361
  handler: async (req, res) => {
9147
9362
  if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
9148
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9363
+ if (!requireLoopback(req, res)) return;
9149
9364
  const region = requestRegion(req, res);
9150
9365
  if (region === void 0) return;
9151
9366
  try {
@@ -9160,7 +9375,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9160
9375
  path: WORKBUDDY_ACCOUNTS_REFRESH_PATH,
9161
9376
  handler: async (req, res) => {
9162
9377
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9163
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9378
+ if (!requireLoopback(req, res)) return;
9164
9379
  const region = requestRegion(req, res);
9165
9380
  if (region === void 0) return;
9166
9381
  try {
@@ -9182,7 +9397,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9182
9397
  path: WORKBUDDY_CHECKIN_PATH,
9183
9398
  handler: async (req, res) => {
9184
9399
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9185
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9400
+ if (!requireLoopback(req, res)) return;
9186
9401
  const region = requestRegion(req, res);
9187
9402
  if (region === void 0) return;
9188
9403
  try {
@@ -9208,7 +9423,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9208
9423
  path: WORKBUDDY_BENEFIT_CLAIM_PATH,
9209
9424
  handler: async (req, res) => {
9210
9425
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9211
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9426
+ if (!requireLoopback(req, res)) return;
9212
9427
  const region = requestRegion(req, res);
9213
9428
  if (region === void 0) return;
9214
9429
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -9246,7 +9461,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9246
9461
  path: WORKBUDDY_MODELS_REFRESH_PATH,
9247
9462
  handler: async (req, res) => {
9248
9463
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9249
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9464
+ if (!requireLoopback(req, res)) return;
9250
9465
  if (deps.discoverModels === void 0) return json(res, 503, { error: "model refresh unavailable" });
9251
9466
  const region = requestRegion(req, res);
9252
9467
  if (region === void 0) return;
@@ -9262,7 +9477,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9262
9477
  path: WORKBUDDY_PROBE_PATH,
9263
9478
  handler: async (req, res) => {
9264
9479
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9265
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9480
+ if (!requireLoopback(req, res)) return;
9266
9481
  if (deps.probeModels === void 0) return json(res, 503, { error: "model probe unavailable" });
9267
9482
  const region = requestRegion(req, res);
9268
9483
  if (region === void 0) return;
@@ -9284,7 +9499,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9284
9499
  path: WORKBUDDY_TPS_PATH,
9285
9500
  handler: async (req, res) => {
9286
9501
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9287
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9502
+ if (!requireLoopback(req, res)) return;
9288
9503
  if (deps.measureTps === void 0) return json(res, 503, { error: "tps measurement unavailable" });
9289
9504
  const region = requestRegion(req, res);
9290
9505
  if (region === void 0) return;
@@ -9306,7 +9521,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9306
9521
  path: WORKBUDDY_POOL_PATH,
9307
9522
  handler: async (req, res) => {
9308
9523
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9309
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9524
+ if (!requireLoopback(req, res)) return;
9310
9525
  const region = requestRegion(req, res);
9311
9526
  if (region === void 0) return;
9312
9527
  const action = poolActionOf(req.url ?? "/");
@@ -9449,7 +9664,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9449
9664
  path: "/plugins/dsh-connect-workbuddy/__save",
9450
9665
  handler: async (req, res) => {
9451
9666
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9452
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9667
+ if (!requireLoopback(req, res)) return;
9453
9668
  const settings = ctx.get?.("settings");
9454
9669
  if (settings === void 0) return json(res, 503, { error: "settings service unavailable to this fiber" });
9455
9670
  try {
@@ -9536,7 +9751,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9536
9751
  path: WORKBUDDY_OAUTH_PATH,
9537
9752
  handler: async (req, res) => {
9538
9753
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9539
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9754
+ if (!requireLoopback(req, res)) return;
9540
9755
  const region = requestRegion(req, res);
9541
9756
  if (region === void 0) return;
9542
9757
  const action = oauthActionOf(req.url ?? "/");
@@ -9564,7 +9779,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9564
9779
  path: WORKBUDDY_TRANSFER_PATH,
9565
9780
  handler: async (req, res) => {
9566
9781
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
9567
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9782
+ if (!requireLoopback(req, res)) return;
9568
9783
  const region = requestRegion(req, res);
9569
9784
  if (region === void 0) return;
9570
9785
  const action = transferActionOf(req.url ?? "/");
@@ -9618,7 +9833,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9618
9833
  path: WORKBUDDY_CLI_LIVE_PATH,
9619
9834
  handler: (req, res) => {
9620
9835
  if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
9621
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
9836
+ if (!requireLoopback(req, res)) return;
9622
9837
  try {
9623
9838
  json(res, 200, deps.cliLive?.() ?? {
9624
9839
  turns: [],
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
- "version": "5.8.4",
5
+ "version": "5.9.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",