dsh-connect-workbuddy 4.0.0 → 4.2.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.
@@ -1,4 +1,4 @@
1
- import { readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { createDecipheriv, createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, win32 } from "node:path";
@@ -736,6 +736,38 @@ function regionOf(domain) {
736
736
  return "cn";
737
737
  }
738
738
  /**
739
+ * The STRICT counterpart of {@link regionOf}: a region's domain only when it is
740
+ * positively recognised, `undefined` for anything else.
741
+ *
742
+ * `regionOf`'s catch-all is deliberate — the CN product is single-homed, so an
743
+ * unrecognised spelling is treated as CN and a real (if oddly written) CN
744
+ * credential still resolves. That reasoning holds only for a domain the
745
+ * UPSTREAM issued. It does not hold for a value read out of a file someone
746
+ * handed the plugin: there "unrecognised" means "cannot be classified", and
747
+ * calling it CN would file a foreign or spoofed credential in the CN vault.
748
+ * Import gates must use this, not {@link regionOf}.
749
+ *
750
+ * Both sides are matched on their registrable domain, so a bare host and any
751
+ * subdomain qualify while a lookalike such as `codebuddy.cn.evil.com` cannot.
752
+ *
753
+ * `copilot.tencent.com` is NOT a third product — it is this plugin's OWN CN
754
+ * chat gateway (`CN_CHAT_BASE`), and the credential's `domain` field carries it
755
+ * verbatim on real records. Refusing it here made the plugin reject its own
756
+ * export: the card exported 8 accounts, and re-importing that file skipped the
757
+ * three whose `domain` was the gateway. It cannot be spoofed into the wrong
758
+ * region by being recognised here — it names the CN gateway, so admitting it is
759
+ * the same decision as admitting `workbuddy.cn`.
760
+ */
761
+ function knownRegionOf(domain) {
762
+ const lowered = domain.trim().toLowerCase();
763
+ if (lowered === "") return void 0;
764
+ if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
765
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "global";
766
+ if (lowered === "workbuddy.cn" || lowered.endsWith(".workbuddy.cn")) return "cn";
767
+ if (lowered === "codebuddy.cn" || lowered.endsWith(".codebuddy.cn")) return "cn";
768
+ if (lowered === "copilot.tencent.com" || lowered.endsWith(".copilot.tencent.com")) return "cn";
769
+ }
770
+ /**
739
771
  * Gateway for a global credential.
740
772
  *
741
773
  * International accounts are NOT interchangeable across brand domains: a token
@@ -1564,8 +1596,14 @@ const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
1564
1596
  const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
1565
1597
  /** Basename of the live WorkBuddy desktop auth file. */
1566
1598
  const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
1567
- /** Prefix of the plugin-owned per-region credential copies. */
1599
+ /** Prefix of the plugin-owned per-region credential copies (legacy, read-only). */
1568
1600
  const WORKBUDDY_OWN_PREFIX = ".workbuddy-auth";
1601
+ /**
1602
+ * Directory name (inside the Harness home) of the plugin's unified credential
1603
+ * vault: every account the plugin knows — scanned from the desktop app AND
1604
+ * added through OAuth sign-in — is persisted there, one file per account.
1605
+ */
1606
+ const WORKBUDDY_VAULT_DIRNAME = "workbuddy-vault";
1569
1607
  /** Current on-disk format of the plugin-owned copy; readers reject others. */
1570
1608
  const OWN_FORMAT_VERSION = 1;
1571
1609
  /**
@@ -1585,6 +1623,92 @@ function legacyWorkbuddyOwnAuthPath() {
1585
1623
  return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
1586
1624
  }
1587
1625
  /**
1626
+ * The vault's directory for one region inside the Harness home.
1627
+ *
1628
+ * The vault is the plugin's UNIFIED credential store: every account the
1629
+ * plugin knows lives here, whether it was scanned from the desktop app's
1630
+ * auth directory or added through OAuth sign-in. One file per account,
1631
+ * named by the account's stable id — so two sources yielding the same
1632
+ * account converge on the same file instead of duplicating it.
1633
+ */
1634
+ function workbuddyVaultDir(region) {
1635
+ return join(resolveDshHome(), WORKBUDDY_VAULT_DIRNAME, region);
1636
+ }
1637
+ /**
1638
+ * Serialize a credential to the vault's on-disk document.
1639
+ *
1640
+ * The on-disk field names are the DESKTOP document's snake-free shapes
1641
+ * (`expiresAt`, `lastRefreshTime`), which is what `parseWorkBuddyAuth`
1642
+ * reads back. Serializing the runtime camelCase fields verbatim parsed as
1643
+ * `expiresAtMs: 0` on the next read — every restart re-refreshed every
1644
+ * account, and a refresh outage could then take down sign-ins that were
1645
+ * perfectly healthy on disk.
1646
+ */
1647
+ function vaultDocument(credential, accountId, refreshedFrom) {
1648
+ return {
1649
+ version: VAULT_FORMAT_VERSION,
1650
+ accountId,
1651
+ refreshedAtMs: Date.now(),
1652
+ ...refreshedFrom === void 0 ? {} : { refreshedFrom: authFileName(refreshedFrom) },
1653
+ auth: {
1654
+ accessToken: credential.accessToken,
1655
+ refreshToken: credential.refreshToken,
1656
+ expiresAt: credential.expiresAtMs > 0 ? Math.floor(credential.expiresAtMs / 1e3) : 0,
1657
+ ...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAt: Math.floor(credential.refreshExpiresAtMs / 1e3) },
1658
+ ...credential.lastRefreshAtMs === void 0 ? {} : { lastRefreshTime: Math.floor(credential.lastRefreshAtMs / 1e3) },
1659
+ domain: credential.domain
1660
+ },
1661
+ account: {
1662
+ uid: credential.uid,
1663
+ ...credential.enterpriseId === void 0 ? {} : { enterpriseId: credential.enterpriseId },
1664
+ ...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
1665
+ ...credential.uin === void 0 ? {} : { uin: credential.uin }
1666
+ }
1667
+ };
1668
+ }
1669
+ /** Parse the vault document; other versions and shapes are rejected. */
1670
+ function parseVaultDocument(text, filePath) {
1671
+ const parsed = parseVaultDocumentWithWriteTime(text, filePath);
1672
+ return parsed === void 0 ? void 0 : parsed.credential;
1673
+ }
1674
+ /**
1675
+ * Parse a vault document into its credential plus the LOCAL write time.
1676
+ *
1677
+ * Internal merge plumbing: `refreshedAtMs` is the freshness signal
1678
+ * {@link isFresherVaultEntry} needs, but it is not part of
1679
+ * {@link WorkBuddyCredential} (the runtime type is shared with OAuth and
1680
+ * desktop reads, none of which carry it).
1681
+ */
1682
+ function parseVaultDocumentWithWriteTime(text, filePath) {
1683
+ let parsed;
1684
+ try {
1685
+ parsed = JSON.parse(text);
1686
+ } catch {
1687
+ return;
1688
+ }
1689
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1690
+ const document = parsed;
1691
+ if (document["version"] !== VAULT_FORMAT_VERSION) return void 0;
1692
+ if (typeof document["auth"] !== "object" || document["auth"] === null) return void 0;
1693
+ const credential = parseWorkBuddyAuth(JSON.stringify({
1694
+ auth: document["auth"],
1695
+ account: document["account"]
1696
+ }), filePath);
1697
+ if (credential === void 0) return void 0;
1698
+ const refreshedAtMs = typeof document["refreshedAtMs"] === "number" && document["refreshedAtMs"] > 0 ? document["refreshedAtMs"] : void 0;
1699
+ const refreshedFrom = typeof document["refreshedFrom"] === "string" && document["refreshedFrom"] !== "" ? document["refreshedFrom"] : void 0;
1700
+ return {
1701
+ credential: {
1702
+ ...credential,
1703
+ source: "dsh"
1704
+ },
1705
+ ...refreshedAtMs === void 0 ? {} : { refreshedAtMs },
1706
+ ...refreshedFrom === void 0 ? {} : { refreshedFrom }
1707
+ };
1708
+ }
1709
+ /** Current on-disk format of the vault; readers reject others. */
1710
+ const VAULT_FORMAT_VERSION = 1;
1711
+ /**
1588
1712
  * Platform-default directories holding the WorkBuddy desktop app's auth file.
1589
1713
  *
1590
1714
  * Windows and Linux prefer the OS-issued env location and fall back to the
@@ -1796,6 +1920,21 @@ function authFileName(path) {
1796
1920
  return separator === -1 ? path : path.slice(separator + 1);
1797
1921
  }
1798
1922
  /**
1923
+ * The exact shape of an account id — sha256 hex sliced to 24 characters, which
1924
+ * is what {@link workbuddyAccountId} emits and therefore the only string a
1925
+ * vault file name can legitimately have.
1926
+ *
1927
+ * An id becomes a path segment, so this is also the traversal guard: an
1928
+ * attacker-supplied `../..` is rejected before it can address anything outside
1929
+ * the vault directory. Exported so the HTTP route can reject a bad id with a
1930
+ * 400 instead of a thrown error.
1931
+ */
1932
+ const VAULT_ACCOUNT_ID_RE = /^[0-9a-f]{24}$/;
1933
+ /** Whether a string is a well-formed account id. */
1934
+ function isVaultAccountId(value) {
1935
+ return typeof value === "string" && VAULT_ACCOUNT_ID_RE.test(value);
1936
+ }
1937
+ /**
1799
1938
  * Stable account id. `uin` is the billing identity the upstream keys on and
1800
1939
  * survives across re-login; `uid` is the fallback for documents without one.
1801
1940
  */
@@ -1803,15 +1942,15 @@ function workbuddyAccountId(credential) {
1803
1942
  const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
1804
1943
  return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 24);
1805
1944
  }
1806
- /** Serialize the plugin-owned copy. */
1807
- function ownDocument(credential, accountId) {
1808
- return {
1809
- version: OWN_FORMAT_VERSION,
1810
- ...accountId === void 0 ? {} : { accountId },
1811
- credential
1812
- };
1813
- }
1814
- /** Parse the plugin-owned copy; other versions and shapes are rejected. */
1945
+ /**
1946
+ * Parse the plugin-owned copy; other versions and shapes are rejected.
1947
+ *
1948
+ * Pre-vault copies serialized the RUNTIME credential verbatim, whose expiry
1949
+ * field is `expiresAtMs` (ms) rather than the desktop document's
1950
+ * `expiresAt` (s) that `parseWorkBuddyAuth` reads — the round trip used to
1951
+ * lose the expiry and force a refresh after every restart. The runtime field
1952
+ * is read back here as the fallback.
1953
+ */
1815
1954
  function parseOwnDocument(text, filePath) {
1816
1955
  let parsed;
1817
1956
  try {
@@ -1825,8 +1964,11 @@ function parseOwnDocument(text, filePath) {
1825
1964
  if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
1826
1965
  const credential = parseWorkBuddyAuth(JSON.stringify({ auth: document["credential"] }), filePath);
1827
1966
  if (credential === void 0) return void 0;
1967
+ const runtime = document["credential"];
1968
+ const legacyExpiry = typeof runtime["expiresAtMs"] === "number" ? expiryToMs(runtime["expiresAtMs"]) : 0;
1828
1969
  return {
1829
1970
  ...credential,
1971
+ ...credential.expiresAtMs === 0 && legacyExpiry > 0 ? { expiresAtMs: legacyExpiry } : {},
1830
1972
  source: "dsh"
1831
1973
  };
1832
1974
  }
@@ -1835,6 +1977,79 @@ function isENOENT(error) {
1835
1977
  return error?.code === "ENOENT";
1836
1978
  }
1837
1979
  /**
1980
+ * Whether a freshly scanned credential should REPLACE a vault entry of the
1981
+ * same account.
1982
+ *
1983
+ * The vault is a CACHE of credentials the machine already holds, not a
1984
+ * competing source. Two clocks decide, compared like against like:
1985
+ *
1986
+ * - The scan's clock is the DESKTOP document's `lastRefreshTime` (the
1987
+ * upstream's own issuance time) — and the live file ranks above every
1988
+ * backup regardless of time, per {@link fileRank}.
1989
+ * - The vault entry's clock is `refreshedAtMs`, the local write time of the
1990
+ * plugin refresh (or OAuth login) that produced it. A wall clock is not
1991
+ * comparable to an issuance time, so the two are NOT mixed: the entry is
1992
+ * replaced only when the scan's read is the live sign-in AND either the
1993
+ * entry predates the scan read's issuance, or the entry was written before
1994
+ * the plugin refreshed into it — approximated by requiring the live file's
1995
+ * issuance to be NEWER than the entry's write time.
1996
+ *
1997
+ * The rule's purpose is breaking the refresh loop: without it, a scan keeps
1998
+ * reinstating the (older, soon-to-expire) desktop token over the refreshed
1999
+ * vault copy, so every restart re-refreshes every account. With it, the
2000
+ * desktop token wins only while it is genuinely newer than what the plugin
2001
+ * last wrote — after that the refreshed copy is the operative credential,
2002
+ * exactly like the pre-vault per-region copies behaved.
2003
+ */
2004
+ function shouldReplaceVaultEntry(candidate, incumbent) {
2005
+ if (fileRank(candidate.filePath) !== 0) return false;
2006
+ const writeMs = incumbent.refreshedAtMs;
2007
+ if (writeMs === void 0) return true;
2008
+ const issuanceMs = candidate.lastRefreshAtMs;
2009
+ if (issuanceMs === void 0) return candidate.expiresAtMs > incumbent.credential.expiresAtMs;
2010
+ return issuanceMs > writeMs;
2011
+ }
2012
+ /**
2013
+ * Read every vault entry for one region directory.
2014
+ *
2015
+ * The map is keyed by account id, which the FILE NAME already carries: the
2016
+ * id inside the document is checked against it and a mismatched file is
2017
+ * skipped — a file copied between accounts (or a half-written rename) must
2018
+ * not silently answer for the wrong account.
2019
+ */
2020
+ async function readVaultEntries(dir) {
2021
+ const entries = /* @__PURE__ */ new Map();
2022
+ let names;
2023
+ try {
2024
+ names = await readdir(dir);
2025
+ } catch (error) {
2026
+ if (isENOENT(error)) return entries;
2027
+ throw error;
2028
+ }
2029
+ for (const name of names) {
2030
+ if (!name.endsWith(".json")) continue;
2031
+ const path = join(dir, name);
2032
+ try {
2033
+ const parsed = parseVaultDocumentWithWriteTime(await readFile(path, "utf8"), path);
2034
+ if (parsed === void 0) continue;
2035
+ const id = name.slice(0, -5);
2036
+ if (workbuddyAccountId(parsed.credential) !== id) continue;
2037
+ entries.set(id, parsed);
2038
+ } catch {}
2039
+ }
2040
+ return entries;
2041
+ }
2042
+ /** Write one vault entry atomically under the file lock. */
2043
+ async function writeVaultEntry(dir, accountId, credential, refreshedFrom) {
2044
+ const path = join(dir, `${accountId}.json`);
2045
+ await withFileLock(path, async () => {
2046
+ await writeFileAtomic(path, `${JSON.stringify(vaultDocument(credential, accountId, refreshedFrom), null, 2)}\n`, {
2047
+ mode: 384,
2048
+ dirMode: 448
2049
+ });
2050
+ });
2051
+ }
2052
+ /**
1838
2053
  * Probe one auth file, reporting WHY it yielded no credential.
1839
2054
  *
1840
2055
  * The desktop app encrypts its token fields on Windows builds. The plain read
@@ -1916,6 +2131,9 @@ var WorkBuddyCredentialStore = class {
1916
2131
  ownPathExplicit;
1917
2132
  legacyOwnPath;
1918
2133
  legacyOwnPathExplicit;
2134
+ vaultDirExplicit;
2135
+ /** The vault directory, resolved ONCE at construction. */
2136
+ vaultDirResolved;
1919
2137
  authDirs;
1920
2138
  resolveAtRestKey;
1921
2139
  desktopPathOverride;
@@ -1938,6 +2156,8 @@ var WorkBuddyCredentialStore = class {
1938
2156
  this.ownPathExplicit = options.ownPath;
1939
2157
  this.legacyOwnPath = options.legacyOwnPath ?? legacyWorkbuddyOwnAuthPath();
1940
2158
  this.legacyOwnPathExplicit = options.legacyOwnPath;
2159
+ this.vaultDirExplicit = options.vaultDir;
2160
+ this.vaultDirResolved = options.vaultDir ?? (options.region !== void 0 ? workbuddyVaultDir(options.region) : join(resolveDshHome(), "workbuddy-vault"));
1941
2161
  this.authDirs = options.authDirs;
1942
2162
  this.desktopPathOverride = options.desktopPath;
1943
2163
  this.resolveAtRestKey = options.resolveAtRestKey ?? readAtRestKey;
@@ -1974,6 +2194,10 @@ var WorkBuddyCredentialStore = class {
1974
2194
  workbuddyOwnAuthPath("global")
1975
2195
  ];
1976
2196
  }
2197
+ /** The vault directory this store reads and writes (fixed at construction). */
2198
+ vaultDir() {
2199
+ return this.vaultDirResolved;
2200
+ }
1977
2201
  /** Repoint the desktop file or directory; applies on the next read. */
1978
2202
  setDesktopPath(path) {
1979
2203
  this.desktopPathOverride = path;
@@ -2087,9 +2311,54 @@ var WorkBuddyCredentialStore = class {
2087
2311
  if (existing === void 0) byId.set(id, own);
2088
2312
  else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
2089
2313
  }
2314
+ await this.syncVault(byId);
2090
2315
  return [...byId.values()];
2091
2316
  }
2092
2317
  /**
2318
+ * Merge the scan's credentials into the vault and read the vault back.
2319
+ *
2320
+ * Incremental by contract: a scan that finds nothing new writes nothing.
2321
+ * Per account, the vault entry is written only when the scan's read is
2322
+ * fresher by {@link isFresherVaultEntry} — so a steady-state scan is
2323
+ * mtime-stable and every sync is O(accounts) reads with at most one write
2324
+ * per CHANGED account.
2325
+ */
2326
+ async syncVault(scan) {
2327
+ const dir = this.vaultDir();
2328
+ let entries;
2329
+ try {
2330
+ entries = await readVaultEntries(dir);
2331
+ } catch {
2332
+ return;
2333
+ }
2334
+ let dirReady = false;
2335
+ for (const [id, credential] of scan) {
2336
+ const incumbent = entries.get(id);
2337
+ if (incumbent !== void 0 && !shouldReplaceVaultEntry(credential, incumbent)) continue;
2338
+ if (!dirReady) try {
2339
+ await mkdir(dir, { recursive: true });
2340
+ dirReady = true;
2341
+ } catch {
2342
+ return;
2343
+ }
2344
+ await writeVaultEntry(dir, id, credential);
2345
+ entries.set(id, {
2346
+ credential: {
2347
+ ...credential,
2348
+ source: "dsh"
2349
+ },
2350
+ refreshedAtMs: Date.now()
2351
+ });
2352
+ }
2353
+ for (const [id, entry] of entries) {
2354
+ if (!this.matchesRegion(entry.credential.domain)) continue;
2355
+ const existing = scan.get(id);
2356
+ if (existing === void 0) scan.set(id, entry.credential);
2357
+ else if (entry.refreshedFrom !== void 0 && entry.refreshedFrom === authFileName(existing.filePath)) scan.set(id, entry.credential);
2358
+ else if (fileRank(existing.filePath) !== 0 && entry.credential.expiresAtMs > existing.expiresAtMs) scan.set(id, entry.credential);
2359
+ }
2360
+ }
2361
+ /**
2093
2362
  * Default when no account is explicitly selected: the live sign-in, else the
2094
2363
  * freshest credential. Following the app's current sign-in is the documented
2095
2364
  * default behaviour; the backups exist so the user can switch explicitly.
@@ -2238,6 +2507,59 @@ var WorkBuddyCredentialStore = class {
2238
2507
  }
2239
2508
  }
2240
2509
  /**
2510
+ * Persist one OAuth sign-in into the unified vault.
2511
+ *
2512
+ * The credential is written with `source: 'dsh'` (it never came from a
2513
+ * desktop file) and joins the readable set on the next scan — the vault is
2514
+ * read by `readAll()` like any other source, so no separate discovery path
2515
+ * exists. The write is idempotent per account id: re-adding an account
2516
+ * replaces its entry, which is exactly what re-issuing a login means.
2517
+ *
2518
+ * Returns the account id so the card can offer to select it right away.
2519
+ */
2520
+ async addOAuthAccount(credential) {
2521
+ const id = workbuddyAccountId(credential);
2522
+ const dir = this.vaultDir();
2523
+ await mkdir(dir, { recursive: true });
2524
+ await writeVaultEntry(dir, id, {
2525
+ ...credential,
2526
+ source: "dsh",
2527
+ filePath: join(dir, `${id}.json`)
2528
+ });
2529
+ this.inflight = void 0;
2530
+ return id;
2531
+ }
2532
+ /**
2533
+ * Remove ONE account's plugin-stored credential by id.
2534
+ *
2535
+ * Only the VAULT entry is deleted — the desktop app's auth files are read-only
2536
+ * to this plugin and are never touched, so an account that still exists as a
2537
+ * desktop sign-in reappears on the next scan. That is the honest behaviour:
2538
+ * the button means "forget what the plugin stored", not "sign the app out".
2539
+ *
2540
+ * Returns whether a stored entry was actually removed, so the caller can
2541
+ * distinguish "removed" from "this account only ever existed as a desktop
2542
+ * sign-in" without a second scan.
2543
+ */
2544
+ async removeAccount(accountId) {
2545
+ if (!VAULT_ACCOUNT_ID_RE.test(accountId)) throw new Error(`workbuddy: refusing to remove '${accountId}': not a vault account id`);
2546
+ const path = join(this.vaultDir(), `${accountId}.json`);
2547
+ const removed = await withFileLock(path, async () => {
2548
+ let existed = true;
2549
+ try {
2550
+ await rm(path, { force: false });
2551
+ } catch (error) {
2552
+ if (!isENOENT(error)) throw error;
2553
+ existed = false;
2554
+ }
2555
+ await rm(`${path}.lock`, { force: false }).catch(() => {});
2556
+ return existed;
2557
+ });
2558
+ if (this.rotatedAccountId === accountId) this.rotatedAccountId = void 0;
2559
+ if (this.accountId === accountId) this.accountId = void 0;
2560
+ return removed;
2561
+ }
2562
+ /**
2241
2563
  * Remove every plugin-owned copy this store could read (per-region file,
2242
2564
  * legacy single file, and their lock siblings); the desktop files are
2243
2565
  * untouched. A region store's logout therefore also clears the legacy
@@ -2249,6 +2571,10 @@ var WorkBuddyCredentialStore = class {
2249
2571
  await rm(path, { force: true });
2250
2572
  await rm(`${path}.lock`, { force: true });
2251
2573
  }
2574
+ await rm(this.vaultDir(), {
2575
+ recursive: true,
2576
+ force: true
2577
+ });
2252
2578
  }
2253
2579
  needsRefresh(credential) {
2254
2580
  if (credential.expiresAtMs <= 0) return true;
@@ -2269,23 +2595,13 @@ var WorkBuddyCredentialStore = class {
2269
2595
  ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
2270
2596
  source: "dsh"
2271
2597
  };
2272
- await this.saveOwn(refreshed);
2598
+ await writeVaultEntry(this.vaultDir(), workbuddyAccountId(credential), refreshed, credential.filePath).catch(() => {});
2273
2599
  return refreshed;
2274
2600
  } catch (error) {
2275
2601
  if (credential.expiresAtMs > Date.now() + 3e4) return credential;
2276
2602
  throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
2277
2603
  }
2278
2604
  }
2279
- async saveOwn(credential) {
2280
- const accountId = workbuddyAccountId(credential);
2281
- const path = this.ownAuthPath();
2282
- await withFileLock(path, async () => {
2283
- await writeFileAtomic(path, `${JSON.stringify(ownDocument(credential, accountId), null, 2)}\n`, {
2284
- mode: 384,
2285
- dirMode: 448
2286
- });
2287
- });
2288
- }
2289
2605
  /**
2290
2606
  * Every readable plugin-owned copy, in candidate order; absent or corrupt
2291
2607
  * files are skipped rather than propagated.
@@ -2360,8 +2676,34 @@ var WorkBuddyCredentialStore = class {
2360
2676
  continue;
2361
2677
  }
2362
2678
  }
2679
+ const tried = [...await this.candidateFiles(), ...this.ownCandidates()];
2680
+ try {
2681
+ const names = await readdir(this.vaultDir());
2682
+ for (const name of names.filter((entry) => entry.endsWith(".json"))) {
2683
+ const path = join(this.vaultDir(), name);
2684
+ tried.push(path);
2685
+ let text;
2686
+ try {
2687
+ text = await readFile(path, "utf8");
2688
+ } catch (error) {
2689
+ if (!isENOENT(error)) candidates.push({
2690
+ path,
2691
+ source: "dsh",
2692
+ reason: "unreadable",
2693
+ message: error instanceof Error ? error.message : String(error)
2694
+ });
2695
+ continue;
2696
+ }
2697
+ if (parseVaultDocument(text, path) === void 0) candidates.push({
2698
+ path,
2699
+ source: "dsh",
2700
+ reason: "invalid",
2701
+ message: "not a readable vault credential"
2702
+ });
2703
+ }
2704
+ } catch {}
2363
2705
  return {
2364
- tried: [...await this.candidateFiles(), ...this.ownCandidates()],
2706
+ tried,
2365
2707
  failures: candidates
2366
2708
  };
2367
2709
  }
@@ -2855,7 +3197,7 @@ function generationOf(version) {
2855
3197
  //#endregion
2856
3198
  //#region src/version.ts
2857
3199
  /** The npm package version this build was produced from. */
2858
- const WORKBUDDY_CONNECT_VERSION = "4.0.0";
3200
+ const WORKBUDDY_CONNECT_VERSION = "4.2.0";
2859
3201
  //#endregion
2860
3202
  //#region src/host-heartbeat.ts
2861
3203
  /**
@@ -3013,4 +3355,4 @@ async function clearHostHeartbeat() {
3013
3355
  await rm(workbuddyHostHeartbeatPath(), { force: true });
3014
3356
  }
3015
3357
  //#endregion
3016
- export { macosNestedAppBundles as $, workbuddyOwnAuthPath as A, parseUpstreamModel as B, defaultDesktopAuthDirs as C, legacyWorkbuddyOwnAuthPath as D, isEncryptedCredentialError as E, classifyUpstreamError as F, deriveAtRestKey as G, regionOf as H, declaredTools as I, findWorkbuddyAppExecutable as J, deriveAtRestKeyId as K, isCredentialRejectedError as L, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as M, WorkBuddyCredentialRejectedError as N, parseWorkBuddyAuth as O, WorkBuddyUpstreamClient as P, macosBundleExecutable as Q, parseCreditMultiplier as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, WORKBUDDY_APP_EXECUTABLE_ENV as U, prepareChatBody as V, clearAtRestKeyCache as W, isEncryptedFieldWrapper as X, findWorkbuddyAppExecutableWithSource as Y, isWorkbuddyBundle as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, openEncryptedField as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, CREDENTIAL_REJECTED_CODE as j, workbuddyAccountId as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, workbuddyAppExecutableCandidates as nt, workbuddyHostHeartbeatPath as o, applyContextBudgets as p, fetchAtRestKeyPayload as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, readAtRestKey as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, parseReasoning as z };
3358
+ export { isWorkbuddyBundle as $, workbuddyAccountId as A, parseCreditMultiplier as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, WORKBUDDY_APP_EXECUTABLE_ENV as G, parseUpstreamModel as H, classifyUpstreamError as I, deriveAtRestKeyId as J, clearAtRestKeyCache as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, isEncryptedFieldWrapper as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, prepareChatBody as U, parseReasoning as V, regionOf as W, findWorkbuddyAppExecutable as X, fetchAtRestKeyPayload as Y, findWorkbuddyAppExecutableWithSource as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, macosBundleExecutable as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, workbuddyAppExecutableCandidates as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, openEncryptedField as nt, workbuddyHostHeartbeatPath as o, applyContextBudgets as p, deriveAtRestKey as q, isHeartbeatProcessAlive as r, readAtRestKey as rt, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosNestedAppBundles as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };