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.
- package/CHANGELOG.md +73 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +715 -592
- package/lib/{host-heartbeat-CAX0oKQT.js → host-heartbeat-C1tVU2IN.js} +52 -9
- package/lib/index.js +292 -77
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
1532
|
-
|
|
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)
|
|
1541
|
-
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
/**
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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.
|
|
5
|
+
"version": "5.9.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|