dsh-connect-workbuddy 2.0.1 → 2.0.3
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 +20 -0
- package/lib/bin.js +1 -1
- package/lib/{host-heartbeat-DjNRPvQf.js → host-heartbeat-CYeVuoJE.js} +95 -12
- package/lib/index.d.ts +16 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.2 (2026-09-15)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **修复「同一账号多个凭据文件时选中已失效的那一个」,导致积分/签到/模型全部返回 openresty HTML 401**。此前按**存储的 `expiresAt` 最大**来挑选凭据,但 `expiresAt` 描述的是「签发时有效期有多长」,**不代表上游仍然接受** —— 上游吊销 token 时不会同步改写该字段,于是一个早已失效的备份文件可以声称比真实可用的文件**更晚过期**,从而永远被选中。
|
|
8
|
+
|
|
9
|
+
- **实测复现**:本机某账号有 8 个凭据文件,其中 `2026-07-08` 的备份声称 `2027-07-06` 过期(最远),而真正可用的当前登录文件只到 `2026-11-14`。旧逻辑因此始终选中那个 07-08 的死文件,三个端点(`get-user-resource` / `checkin-activity-status` / `models`)全部返回 `401 Authorization Required` + `openresty`。
|
|
10
|
+
- **改用 `auth.lastRefreshTime` 作为新鲜度判据**:这是上游自己的签发时间,是唯一可靠的信号。新增 `WorkBuddyCredential.lastRefreshAtMs` 字段并在解析时读入;`preferred()`、多账号去重、以及插件自有副本的合并**三处**统一改用新的 `isFresher()` 比较器。
|
|
11
|
+
- **排序优先级**:① live 文件 `workbuddy-desktop.info`(应用当前登录)→ ② `lastRefreshAtMs` 较新者 → ③ `expiresAtMs`(仅作为文档缺失该字段时的回退,保证比较是全序的)。
|
|
12
|
+
- **实测验证**:切换判据后,本机 11 个账号选中的凭据**全部返回 200**(此前 `老丁`、`LaoDing` 等均命中死文件)。
|
|
13
|
+
- 插件自有刷新副本(无 `lastRefreshTime`)不参与签发时间比较:它只在**确实更长寿**时取代桌面端备份,且**永不顶替 live 登录**。
|
|
14
|
+
|
|
15
|
+
- **上游返回 HTML 401 时给出可执行的提示**,不再原样抛出 HTML 片段。识别 openresty / APISIX 的鉴权拒绝,改为提示「凭据已被上游网关拒绝,通常说明选中的是旧登录留下的失效凭据;请重新登录 WorkBuddy 桌面端后在卡片中选择该账号,可运行 `dsh-connect-workbuddy doctor` 查看全部凭据」。非鉴权类的非 JSON 响应(如 502 网关错误)保持原有的通用提示。
|
|
16
|
+
|
|
17
|
+
### Tests
|
|
18
|
+
|
|
19
|
+
- `auth.spec.ts` 新增 4 例:**备份之间**按签发时间而非存储过期时间选择(回归锚点,旧逻辑下必失败)、文档缺失 `lastRefreshTime` 时回退到过期时间、`auth.lastRefreshTime` 的正确解析、缺失该字段时保持 `undefined`。
|
|
20
|
+
- `upstream.spec.ts` 新增 2 例:网关 HTML 401 被转写为可执行提示(且不泄漏 `<html>`)、非鉴权类的非 JSON 失败仍保留通用提示。
|
|
21
|
+
- 全套 **126 个测试通过**。
|
|
22
|
+
|
|
3
23
|
## 2.0.1 (2026-09-15)
|
|
4
24
|
|
|
5
25
|
### Fixes
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-
|
|
2
|
+
import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CYeVuoJE.js";
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
//#region src/bin.ts
|
|
@@ -221,12 +221,27 @@ function normalizeToolChoice(obj) {
|
|
|
221
221
|
}
|
|
222
222
|
delete obj["tool_choice"];
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Gateway (openresty/APISIX) rejection of a token it no longer accepts.
|
|
226
|
+
*
|
|
227
|
+
* The business APIs answer JSON; an edge rejection answers an HTML error page
|
|
228
|
+
* instead. A 401 that is not JSON therefore means the credential was refused
|
|
229
|
+
* before routing — almost always a revoked/expired token rather than a bug in
|
|
230
|
+
* the request. Detected from the body so a proxy's own error page (which would
|
|
231
|
+
* also be HTML) is still described accurately.
|
|
232
|
+
*/
|
|
233
|
+
function isGatewayAuthRejection(status, text) {
|
|
234
|
+
if (status !== 401 && status !== 403) return false;
|
|
235
|
+
const lower = text.toLowerCase();
|
|
236
|
+
return lower.includes("openresty") || lower.includes("apisix") || lower.includes("authorization required");
|
|
237
|
+
}
|
|
224
238
|
async function readEnvelope(response) {
|
|
225
239
|
const text = await response.text();
|
|
226
240
|
let parsed;
|
|
227
241
|
try {
|
|
228
242
|
parsed = JSON.parse(text);
|
|
229
243
|
} catch {
|
|
244
|
+
if (isGatewayAuthRejection(response.status, text)) throw new Error(`workbuddy: the signed-in credential was rejected by the upstream gateway (http ${response.status}). The stored token is no longer accepted — most likely a stale credential file from an earlier sign-in was selected. Re-sign in to the WorkBuddy desktop app, then pick that account in the plugin card. Run \`dsh-connect-workbuddy doctor\` to list every discovered credential.`);
|
|
230
245
|
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
231
246
|
}
|
|
232
247
|
if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
|
|
@@ -256,13 +271,56 @@ function parseCreditMultiplier(value) {
|
|
|
256
271
|
const parsed = Number(match[1]);
|
|
257
272
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
258
273
|
}
|
|
259
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* The effort vocabulary the upstream's plural-form payloads declare across both
|
|
276
|
+
* gateways (live union of every `supportedEfforts` list seen; `minimal` has
|
|
277
|
+
* never appeared). Both gateways also accept every level in it on
|
|
278
|
+
* singular-form models — medium/xhigh fold into high, low/max answer with
|
|
279
|
+
* their own budgets — so the singular `effort` value is a DEFAULT, never the
|
|
280
|
+
* model's only level.
|
|
281
|
+
*/
|
|
282
|
+
const SINGULAR_EFFORT_LADDER = [
|
|
283
|
+
"low",
|
|
284
|
+
"medium",
|
|
285
|
+
"high",
|
|
286
|
+
"xhigh",
|
|
287
|
+
"max"
|
|
288
|
+
];
|
|
289
|
+
/**
|
|
290
|
+
* The singular spelling of reasoning metadata: `effort` (plus the ignored
|
|
291
|
+
* `summary`) and none of the plural-form fields. Observed on CN
|
|
292
|
+
* `deepseek-v4.1-flash`/`kimi-k3-1`/`glm-5.2`… and global
|
|
293
|
+
* `deepseek-v4.1-flash`/`kimi-k3`/`gemini-3.5-flash`… (issue #7).
|
|
294
|
+
*/
|
|
295
|
+
function isSingularEffortForm(raw) {
|
|
296
|
+
return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Fold a singular-form `effort` into the plural shape the rest of the plugin
|
|
300
|
+
* already understands. Live probes on both gateways (issue #7) show these
|
|
301
|
+
* models answer with distinct `reasoning_content` across the whole ladder —
|
|
302
|
+
* and think NOT AT ALL when no `reasoning_effort` is sent — so the fold
|
|
303
|
+
* widens `supportedEfforts` and carries the declared value into
|
|
304
|
+
* `defaultEffort`. An unrecognized `effort` value passes through as the lone
|
|
305
|
+
* level, leaving its fate to the adapter's known-level filter.
|
|
306
|
+
*/
|
|
307
|
+
function singularEffortLadder(raw) {
|
|
308
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
309
|
+
if (effort === void 0) return void 0;
|
|
310
|
+
return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`.
|
|
314
|
+
* Both spellings normalize here: the plural form passes through as declared,
|
|
315
|
+
* and the singular `effort` form folds via {@link singularEffortLadder}.
|
|
316
|
+
*/
|
|
260
317
|
function parseReasoning(value) {
|
|
261
318
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
262
319
|
const raw = value;
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
const
|
|
320
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
321
|
+
const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
|
|
322
|
+
const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
|
|
323
|
+
const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : isSingularEffortForm(raw) ? true : void 0;
|
|
266
324
|
if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
|
|
267
325
|
return {
|
|
268
326
|
...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
|
|
@@ -673,6 +731,7 @@ function parseWorkBuddyAuth(text, filePath) {
|
|
|
673
731
|
if (accessToken === "") return void 0;
|
|
674
732
|
const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
|
|
675
733
|
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
734
|
+
const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
|
|
676
735
|
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
677
736
|
const nickname = optionalString(identity["nickname"]);
|
|
678
737
|
const uin = optionalString(identity["uin"]);
|
|
@@ -686,6 +745,7 @@ function parseWorkBuddyAuth(text, filePath) {
|
|
|
686
745
|
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
687
746
|
...nickname === void 0 ? {} : { nickname },
|
|
688
747
|
...uin === void 0 ? {} : { uin },
|
|
748
|
+
...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
|
|
689
749
|
source: "desktop",
|
|
690
750
|
filePath
|
|
691
751
|
};
|
|
@@ -704,6 +764,32 @@ function fileRank(path) {
|
|
|
704
764
|
return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
|
|
705
765
|
}
|
|
706
766
|
/**
|
|
767
|
+
* Whether `candidate` is a better pick than `incumbent` for the same account.
|
|
768
|
+
*
|
|
769
|
+
* Ordering, strongest signal first:
|
|
770
|
+
*
|
|
771
|
+
* 1. the live `workbuddy-desktop.info` (the app's current sign-in);
|
|
772
|
+
* 2. the most recent `lastRefreshAtMs` — the upstream's own issuance time;
|
|
773
|
+
* 3. `expiresAtMs`, only as a fallback for documents that omit the field.
|
|
774
|
+
*
|
|
775
|
+
* Step 2 is what makes this correct. `expiresAt` describes how long the token
|
|
776
|
+
* was VALID FOR at issue time, not whether it is still accepted: a revoked
|
|
777
|
+
* backup keeps a far-future `expiresAt` (2027 in the observed case) and would
|
|
778
|
+
* otherwise outrank the working live credential, which is exactly how a
|
|
779
|
+
* signed-in account turned into an upstream HTML 401.
|
|
780
|
+
*/
|
|
781
|
+
function isFresher(candidate, incumbent) {
|
|
782
|
+
const rankDiff = fileRank(candidate.filePath) - fileRank(incumbent.filePath);
|
|
783
|
+
if (rankDiff !== 0) return rankDiff < 0;
|
|
784
|
+
const candidateRefresh = candidate.lastRefreshAtMs;
|
|
785
|
+
const incumbentRefresh = incumbent.lastRefreshAtMs;
|
|
786
|
+
if (candidateRefresh !== void 0 && incumbentRefresh !== void 0) {
|
|
787
|
+
if (candidateRefresh !== incumbentRefresh) return candidateRefresh > incumbentRefresh;
|
|
788
|
+
} else if (candidateRefresh !== void 0) return true;
|
|
789
|
+
else if (incumbentRefresh !== void 0) return false;
|
|
790
|
+
return candidate.expiresAtMs > incumbent.expiresAtMs;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
707
793
|
* Filename of a path regardless of the host separator: Windows paths use `\`
|
|
708
794
|
* and this helper must keep working when a Windows path is compared on a
|
|
709
795
|
* POSIX host (e.g. tests injecting a Windows-style auth dir).
|
|
@@ -906,13 +992,14 @@ var WorkBuddyCredentialStore = class {
|
|
|
906
992
|
byId.set(id, credential);
|
|
907
993
|
continue;
|
|
908
994
|
}
|
|
909
|
-
if (
|
|
995
|
+
if (isFresher(credential, existing)) byId.set(id, credential);
|
|
910
996
|
}
|
|
911
997
|
for (const own of await this.readOwns()) {
|
|
912
998
|
if (!this.matchesRegion(own.domain)) continue;
|
|
913
999
|
const id = workbuddyAccountId(own);
|
|
914
1000
|
const existing = byId.get(id);
|
|
915
|
-
if (existing === void 0
|
|
1001
|
+
if (existing === void 0) byId.set(id, own);
|
|
1002
|
+
else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
|
|
916
1003
|
}
|
|
917
1004
|
return [...byId.values()];
|
|
918
1005
|
}
|
|
@@ -925,11 +1012,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
925
1012
|
*/
|
|
926
1013
|
preferred(credentials) {
|
|
927
1014
|
if (credentials.length === 0) return void 0;
|
|
928
|
-
return credentials.reduce((best, credential) =>
|
|
929
|
-
if (fileRank(credential.filePath) < fileRank(best.filePath)) return credential;
|
|
930
|
-
if (fileRank(credential.filePath) === fileRank(best.filePath) && credential.expiresAtMs > best.expiresAtMs) return credential;
|
|
931
|
-
return best;
|
|
932
|
-
});
|
|
1015
|
+
return credentials.reduce((best, credential) => isFresher(credential, best) ? credential : best);
|
|
933
1016
|
}
|
|
934
1017
|
/** Token-free account list for the plugin card. */
|
|
935
1018
|
async accounts() {
|
|
@@ -1330,7 +1413,7 @@ var WorkBuddyCatalog = class {
|
|
|
1330
1413
|
//#endregion
|
|
1331
1414
|
//#region src/version.ts
|
|
1332
1415
|
/** The npm package version this build was produced from. */
|
|
1333
|
-
const WORKBUDDY_CONNECT_VERSION = "2.0.
|
|
1416
|
+
const WORKBUDDY_CONNECT_VERSION = "2.0.3";
|
|
1334
1417
|
//#endregion
|
|
1335
1418
|
//#region src/host-heartbeat.ts
|
|
1336
1419
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ interface WorkBuddyCredential {
|
|
|
19
19
|
source: 'desktop' | 'dsh';
|
|
20
20
|
/** Absolute path of the auth file this credential was read from. */
|
|
21
21
|
filePath: string;
|
|
22
|
+
/**
|
|
23
|
+
* Epoch ms the upstream last issued this token (`auth.lastRefreshTime`).
|
|
24
|
+
*
|
|
25
|
+
* This is the ONLY trustworthy freshness signal. `expiresAtMs` cannot be
|
|
26
|
+
* used for ranking: when the upstream revokes a token it leaves the stored
|
|
27
|
+
* `expiresAt` untouched, so a long-dead backup can claim a LATER expiry than
|
|
28
|
+
* the live sign-in (observed on a real machine — a 2026-07-08 backup claimed
|
|
29
|
+
* 2027-07-06 while the live file expired 2026-11-14, and only the live file
|
|
30
|
+
* was accepted). Undefined when the document omits the field.
|
|
31
|
+
*/
|
|
32
|
+
lastRefreshAtMs?: number;
|
|
22
33
|
}
|
|
23
34
|
/** Read-only sign-in summary for status and doctor output. */
|
|
24
35
|
interface WorkBuddyAuthStatus {
|
|
@@ -349,7 +360,11 @@ declare function prepareChatBody(source: string): string;
|
|
|
349
360
|
* card simply omits the rate instead of displaying a fabricated one.
|
|
350
361
|
*/
|
|
351
362
|
declare function parseCreditMultiplier(value: unknown): number | undefined;
|
|
352
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`.
|
|
365
|
+
* Both spellings normalize here: the plural form passes through as declared,
|
|
366
|
+
* and the singular `effort` form folds via {@link singularEffortLadder}.
|
|
367
|
+
*/
|
|
353
368
|
declare function parseReasoning(value: unknown): WorkBuddyReasoning | undefined;
|
|
354
369
|
/** Parse one catalog entry; entries without usable token limits are dropped. */
|
|
355
370
|
declare function parseUpstreamModel(value: unknown): WorkBuddyUpstreamModel | undefined;
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-
|
|
1
|
+
import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CYeVuoJE.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { createProvider } from "@earendil-works/pi-ai";
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "Connect locally signed-in WorkBuddy models to DeepSeek Harness with a read-only credits overview and model management.",
|
|
5
|
-
"version": "2.0.
|
|
5
|
+
"version": "2.0.3",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|