dsh-connect-workbuddy 2.0.1 → 2.0.2

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 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-DjNRPvQf.js";
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-CaS5Koaw.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})`);
@@ -673,6 +688,7 @@ function parseWorkBuddyAuth(text, filePath) {
673
688
  if (accessToken === "") return void 0;
674
689
  const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
675
690
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
691
+ const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
676
692
  const enterpriseId = optionalString(identity["enterpriseId"]);
677
693
  const nickname = optionalString(identity["nickname"]);
678
694
  const uin = optionalString(identity["uin"]);
@@ -686,6 +702,7 @@ function parseWorkBuddyAuth(text, filePath) {
686
702
  ...enterpriseId === void 0 ? {} : { enterpriseId },
687
703
  ...nickname === void 0 ? {} : { nickname },
688
704
  ...uin === void 0 ? {} : { uin },
705
+ ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
689
706
  source: "desktop",
690
707
  filePath
691
708
  };
@@ -704,6 +721,32 @@ function fileRank(path) {
704
721
  return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
705
722
  }
706
723
  /**
724
+ * Whether `candidate` is a better pick than `incumbent` for the same account.
725
+ *
726
+ * Ordering, strongest signal first:
727
+ *
728
+ * 1. the live `workbuddy-desktop.info` (the app's current sign-in);
729
+ * 2. the most recent `lastRefreshAtMs` — the upstream's own issuance time;
730
+ * 3. `expiresAtMs`, only as a fallback for documents that omit the field.
731
+ *
732
+ * Step 2 is what makes this correct. `expiresAt` describes how long the token
733
+ * was VALID FOR at issue time, not whether it is still accepted: a revoked
734
+ * backup keeps a far-future `expiresAt` (2027 in the observed case) and would
735
+ * otherwise outrank the working live credential, which is exactly how a
736
+ * signed-in account turned into an upstream HTML 401.
737
+ */
738
+ function isFresher(candidate, incumbent) {
739
+ const rankDiff = fileRank(candidate.filePath) - fileRank(incumbent.filePath);
740
+ if (rankDiff !== 0) return rankDiff < 0;
741
+ const candidateRefresh = candidate.lastRefreshAtMs;
742
+ const incumbentRefresh = incumbent.lastRefreshAtMs;
743
+ if (candidateRefresh !== void 0 && incumbentRefresh !== void 0) {
744
+ if (candidateRefresh !== incumbentRefresh) return candidateRefresh > incumbentRefresh;
745
+ } else if (candidateRefresh !== void 0) return true;
746
+ else if (incumbentRefresh !== void 0) return false;
747
+ return candidate.expiresAtMs > incumbent.expiresAtMs;
748
+ }
749
+ /**
707
750
  * Filename of a path regardless of the host separator: Windows paths use `\`
708
751
  * and this helper must keep working when a Windows path is compared on a
709
752
  * POSIX host (e.g. tests injecting a Windows-style auth dir).
@@ -906,13 +949,14 @@ var WorkBuddyCredentialStore = class {
906
949
  byId.set(id, credential);
907
950
  continue;
908
951
  }
909
- if (fileRank(credential.filePath) < fileRank(existing.filePath) || fileRank(credential.filePath) === fileRank(existing.filePath) && credential.expiresAtMs > existing.expiresAtMs) byId.set(id, credential);
952
+ if (isFresher(credential, existing)) byId.set(id, credential);
910
953
  }
911
954
  for (const own of await this.readOwns()) {
912
955
  if (!this.matchesRegion(own.domain)) continue;
913
956
  const id = workbuddyAccountId(own);
914
957
  const existing = byId.get(id);
915
- if (existing === void 0 || own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
958
+ if (existing === void 0) byId.set(id, own);
959
+ else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
916
960
  }
917
961
  return [...byId.values()];
918
962
  }
@@ -925,11 +969,7 @@ var WorkBuddyCredentialStore = class {
925
969
  */
926
970
  preferred(credentials) {
927
971
  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
- });
972
+ return credentials.reduce((best, credential) => isFresher(credential, best) ? credential : best);
933
973
  }
934
974
  /** Token-free account list for the plugin card. */
935
975
  async accounts() {
@@ -1330,7 +1370,7 @@ var WorkBuddyCatalog = class {
1330
1370
  //#endregion
1331
1371
  //#region src/version.ts
1332
1372
  /** The npm package version this build was produced from. */
1333
- const WORKBUDDY_CONNECT_VERSION = "2.0.1";
1373
+ const WORKBUDDY_CONNECT_VERSION = "2.0.2";
1334
1374
  //#endregion
1335
1375
  //#region src/host-heartbeat.ts
1336
1376
  /**
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 {
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-DjNRPvQf.js";
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-CaS5Koaw.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.1",
5
+ "version": "2.0.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",