dsh-connect-workbuddy 4.0.0 → 4.3.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,193 @@ 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
+ /**
1710
+ * File name of the vault's DISMISSAL list, stored beside the vault entries.
1711
+ *
1712
+ * It exists because deleting a vault entry is not the same as deleting an
1713
+ * account. `syncVault` writes every desktop sign-in it scans back into the
1714
+ * vault — that is how a desktop account becomes selectable — so removing the
1715
+ * vault file alone was undone by the very next scan: the row came back and the
1716
+ * button read as broken (measured: `removeAccount` returns true, `accounts()`
1717
+ * still lists it).
1718
+ *
1719
+ * Dismissal means HIDDEN UNTIL THE USER SCANS AGAIN, not deleted forever. A
1720
+ * desktop sign-in is still there in the app, and the manual 「re-scan」 action is
1721
+ * the user asking to see what the machine currently has — so
1722
+ * {@link WorkBuddyCredentialStore.clearDismissals} empties this list on every
1723
+ * manual scan and the accounts return. Without that, a removed account could
1724
+ * never be recovered except by reinstalling or re-signing in.
1725
+ *
1726
+ * The list must survive the automatic refreshes in between: the card polls the
1727
+ * usage route every 60 seconds, and an in-memory-only dismissal would let the
1728
+ * account reappear a minute after the user removed it. Hence a file.
1729
+ *
1730
+ * Dismissal is per ACCOUNT, not per source: hiding only the vault copy would
1731
+ * leave the same account listed from the desktop file, which is the
1732
+ * half-measure that produced the report.
1733
+ */
1734
+ const VAULT_DISMISSED_FILENAME = ".dismissed.json";
1735
+ /** Current on-disk format of the dismissal list; readers reject others. */
1736
+ const DISMISSED_FORMAT_VERSION = 1;
1737
+ /**
1738
+ * How long one scan may be shared across callers, in milliseconds.
1739
+ *
1740
+ * One page open asks the store for the same account list six times (usage
1741
+ * route: accounts, status, resolve; the credits panel: once PER ACCOUNT through
1742
+ * `credentialFor`; and both region tabs poll together). Each read re-listed the
1743
+ * auth directory, re-parsed every file and re-ran the vault merge — measured at
1744
+ * 6 `readdir` and (6N+12) `readFile` for N accounts.
1745
+ *
1746
+ * Why a WINDOW and not a plain in-flight share: the six reads are SEQUENTIAL
1747
+ * (`await` one after another), so by the time the second starts the first has
1748
+ * resolved and there is nothing in flight to join. Sharing only concurrent reads
1749
+ * optimised nothing at all.
1750
+ *
1751
+ * Why so short: this is not a cache of the account list, it is an admission that
1752
+ * one page open IS one logical read. Anything longer would delay noticing a
1753
+ * sign-in the desktop app made on its own — discovery IS polling, so this window
1754
+ * is how long such a change may stay unseen. The card polls every 60 s, so 1 s is
1755
+ * invisible to the user while collapsing one page open from six scans to one. A
1756
+ * write through this store invalidates the window outright, so the actions the
1757
+ * user takes here (OAuth add, import, remove, rescan) are never delayed by it.
1758
+ */
1759
+ const SCAN_SHARE_WINDOW_MS = 1e3;
1760
+ /** Read the dismissal list; an absent or unreadable file reads as empty. */
1761
+ async function readDismissed(dir) {
1762
+ const dismissed = /* @__PURE__ */ new Set();
1763
+ try {
1764
+ const parsed = JSON.parse(await readFile(join(dir, VAULT_DISMISSED_FILENAME), "utf8"));
1765
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return dismissed;
1766
+ const document = parsed;
1767
+ if (document["version"] !== DISMISSED_FORMAT_VERSION) return dismissed;
1768
+ const ids = document["accountIds"];
1769
+ if (!Array.isArray(ids)) return dismissed;
1770
+ for (const id of ids) if (typeof id === "string" && VAULT_ACCOUNT_ID_RE.test(id)) dismissed.add(id);
1771
+ } catch {}
1772
+ return dismissed;
1773
+ }
1774
+ /** Write the dismissal list atomically under its own lock. */
1775
+ async function writeDismissed(dir, dismissed) {
1776
+ const path = join(dir, VAULT_DISMISSED_FILENAME);
1777
+ const ids = [...dismissed].slice(-1e3);
1778
+ const document = {
1779
+ version: DISMISSED_FORMAT_VERSION,
1780
+ accountIds: ids
1781
+ };
1782
+ await withFileLock(path, async () => {
1783
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
1784
+ mode: 384,
1785
+ dirMode: 448
1786
+ });
1787
+ });
1788
+ }
1789
+ /**
1790
+ * Whether a directory entry is ONE ACCOUNT's vault credential file.
1791
+ *
1792
+ * The vault directory holds more than credentials: `writeDismissed` puts
1793
+ * `.dismissed.json` here, and atomic writes transiently leave
1794
+ * `<id>.json.<hex>.tmp` siblings behind. Both used to be swept up by a bare
1795
+ * `endsWith('.json')` check, and the DISMISSAL file in particular is
1796
+ * well-formed JSON that is not a credential — so `diagnose` reported it at the
1797
+ * user as 「not a readable vault credential」 on any machine where an account
1798
+ * had been removed, turning a healthy sign-in into an unreadable-looking one.
1799
+ *
1800
+ * The rule is the account id shape the store itself writes
1801
+ * ({@link VAULT_ACCOUNT_ID_RE}), not a naming convention: a file qualifies only
1802
+ * when its stem is a real id, which no bookkeeping or temp file can be. A
1803
+ * `.tmp` sibling therefore fails on the dot of its extension, and a hidden file
1804
+ * fails on its leading dot even before that.
1805
+ */
1806
+ function isVaultCredentialFileName(name) {
1807
+ if (!name.endsWith(".json")) return false;
1808
+ return VAULT_ACCOUNT_ID_RE.test(name.slice(0, -5));
1809
+ }
1810
+ /** Current on-disk format of the vault; readers reject others. */
1811
+ const VAULT_FORMAT_VERSION = 1;
1812
+ /**
1588
1813
  * Platform-default directories holding the WorkBuddy desktop app's auth file.
1589
1814
  *
1590
1815
  * Windows and Linux prefer the OS-issued env location and fall back to the
@@ -1796,6 +2021,21 @@ function authFileName(path) {
1796
2021
  return separator === -1 ? path : path.slice(separator + 1);
1797
2022
  }
1798
2023
  /**
2024
+ * The exact shape of an account id — sha256 hex sliced to 24 characters, which
2025
+ * is what {@link workbuddyAccountId} emits and therefore the only string a
2026
+ * vault file name can legitimately have.
2027
+ *
2028
+ * An id becomes a path segment, so this is also the traversal guard: an
2029
+ * attacker-supplied `../..` is rejected before it can address anything outside
2030
+ * the vault directory. Exported so the HTTP route can reject a bad id with a
2031
+ * 400 instead of a thrown error.
2032
+ */
2033
+ const VAULT_ACCOUNT_ID_RE = /^[0-9a-f]{24}$/;
2034
+ /** Whether a string is a well-formed account id. */
2035
+ function isVaultAccountId(value) {
2036
+ return typeof value === "string" && VAULT_ACCOUNT_ID_RE.test(value);
2037
+ }
2038
+ /**
1799
2039
  * Stable account id. `uin` is the billing identity the upstream keys on and
1800
2040
  * survives across re-login; `uid` is the fallback for documents without one.
1801
2041
  */
@@ -1803,15 +2043,15 @@ function workbuddyAccountId(credential) {
1803
2043
  const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
1804
2044
  return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 24);
1805
2045
  }
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. */
2046
+ /**
2047
+ * Parse the plugin-owned copy; other versions and shapes are rejected.
2048
+ *
2049
+ * Pre-vault copies serialized the RUNTIME credential verbatim, whose expiry
2050
+ * field is `expiresAtMs` (ms) rather than the desktop document's
2051
+ * `expiresAt` (s) that `parseWorkBuddyAuth` reads — the round trip used to
2052
+ * lose the expiry and force a refresh after every restart. The runtime field
2053
+ * is read back here as the fallback.
2054
+ */
1815
2055
  function parseOwnDocument(text, filePath) {
1816
2056
  let parsed;
1817
2057
  try {
@@ -1825,8 +2065,11 @@ function parseOwnDocument(text, filePath) {
1825
2065
  if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
1826
2066
  const credential = parseWorkBuddyAuth(JSON.stringify({ auth: document["credential"] }), filePath);
1827
2067
  if (credential === void 0) return void 0;
2068
+ const runtime = document["credential"];
2069
+ const legacyExpiry = typeof runtime["expiresAtMs"] === "number" ? expiryToMs(runtime["expiresAtMs"]) : 0;
1828
2070
  return {
1829
2071
  ...credential,
2072
+ ...credential.expiresAtMs === 0 && legacyExpiry > 0 ? { expiresAtMs: legacyExpiry } : {},
1830
2073
  source: "dsh"
1831
2074
  };
1832
2075
  }
@@ -1835,6 +2078,79 @@ function isENOENT(error) {
1835
2078
  return error?.code === "ENOENT";
1836
2079
  }
1837
2080
  /**
2081
+ * Whether a freshly scanned credential should REPLACE a vault entry of the
2082
+ * same account.
2083
+ *
2084
+ * The vault is a CACHE of credentials the machine already holds, not a
2085
+ * competing source. Two clocks decide, compared like against like:
2086
+ *
2087
+ * - The scan's clock is the DESKTOP document's `lastRefreshTime` (the
2088
+ * upstream's own issuance time) — and the live file ranks above every
2089
+ * backup regardless of time, per {@link fileRank}.
2090
+ * - The vault entry's clock is `refreshedAtMs`, the local write time of the
2091
+ * plugin refresh (or OAuth login) that produced it. A wall clock is not
2092
+ * comparable to an issuance time, so the two are NOT mixed: the entry is
2093
+ * replaced only when the scan's read is the live sign-in AND either the
2094
+ * entry predates the scan read's issuance, or the entry was written before
2095
+ * the plugin refreshed into it — approximated by requiring the live file's
2096
+ * issuance to be NEWER than the entry's write time.
2097
+ *
2098
+ * The rule's purpose is breaking the refresh loop: without it, a scan keeps
2099
+ * reinstating the (older, soon-to-expire) desktop token over the refreshed
2100
+ * vault copy, so every restart re-refreshes every account. With it, the
2101
+ * desktop token wins only while it is genuinely newer than what the plugin
2102
+ * last wrote — after that the refreshed copy is the operative credential,
2103
+ * exactly like the pre-vault per-region copies behaved.
2104
+ */
2105
+ function shouldReplaceVaultEntry(candidate, incumbent) {
2106
+ if (fileRank(candidate.filePath) !== 0) return false;
2107
+ const writeMs = incumbent.refreshedAtMs;
2108
+ if (writeMs === void 0) return true;
2109
+ const issuanceMs = candidate.lastRefreshAtMs;
2110
+ if (issuanceMs === void 0) return candidate.expiresAtMs > incumbent.credential.expiresAtMs;
2111
+ return issuanceMs > writeMs;
2112
+ }
2113
+ /**
2114
+ * Read every vault entry for one region directory.
2115
+ *
2116
+ * The map is keyed by account id, which the FILE NAME already carries: the
2117
+ * id inside the document is checked against it and a mismatched file is
2118
+ * skipped — a file copied between accounts (or a half-written rename) must
2119
+ * not silently answer for the wrong account.
2120
+ */
2121
+ async function readVaultEntries(dir) {
2122
+ const entries = /* @__PURE__ */ new Map();
2123
+ let names;
2124
+ try {
2125
+ names = await readdir(dir);
2126
+ } catch (error) {
2127
+ if (isENOENT(error)) return entries;
2128
+ throw error;
2129
+ }
2130
+ for (const name of names) {
2131
+ if (!isVaultCredentialFileName(name)) continue;
2132
+ const path = join(dir, name);
2133
+ try {
2134
+ const parsed = parseVaultDocumentWithWriteTime(await readFile(path, "utf8"), path);
2135
+ if (parsed === void 0) continue;
2136
+ const id = name.slice(0, -5);
2137
+ if (workbuddyAccountId(parsed.credential) !== id) continue;
2138
+ entries.set(id, parsed);
2139
+ } catch {}
2140
+ }
2141
+ return entries;
2142
+ }
2143
+ /** Write one vault entry atomically under the file lock. */
2144
+ async function writeVaultEntry(dir, accountId, credential, refreshedFrom) {
2145
+ const path = join(dir, `${accountId}.json`);
2146
+ await withFileLock(path, async () => {
2147
+ await writeFileAtomic(path, `${JSON.stringify(vaultDocument(credential, accountId, refreshedFrom), null, 2)}\n`, {
2148
+ mode: 384,
2149
+ dirMode: 448
2150
+ });
2151
+ });
2152
+ }
2153
+ /**
1838
2154
  * Probe one auth file, reporting WHY it yielded no credential.
1839
2155
  *
1840
2156
  * The desktop app encrypts its token fields on Windows builds. The plain read
@@ -1916,12 +2232,37 @@ var WorkBuddyCredentialStore = class {
1916
2232
  ownPathExplicit;
1917
2233
  legacyOwnPath;
1918
2234
  legacyOwnPathExplicit;
2235
+ vaultDirExplicit;
2236
+ /** The vault directory, resolved ONCE at construction. */
2237
+ vaultDirResolved;
1919
2238
  authDirs;
1920
2239
  resolveAtRestKey;
1921
2240
  desktopPathOverride;
1922
2241
  accountId;
1923
2242
  inflight;
1924
2243
  /**
2244
+ * In-flight/completed scan for the CURRENT `readAll` generation.
2245
+ *
2246
+ * One page open calls `readAll` six times: the usage route asks for the
2247
+ * account list, the sign-in state and the resolved credential, then the
2248
+ * per-account credits panel asks once PER ACCOUNT (`credentialFor`), and both
2249
+ * region tabs poll on the same timer. Each call re-read every auth file,
2250
+ * re-parsed it, and re-ran the vault merge — measured at 6 `readdir` + (6N+12)
2251
+ * `readFile` for N accounts, i.e. the cost grew with the account count on a
2252
+ * path whose answer cannot change within one tick.
2253
+ *
2254
+ * This is deliberately NOT a time-based cache: a TTL would make the card show
2255
+ * a stale account list after an OAuth add or an import, which is the very
2256
+ * class of bug this card keeps chasing. Instead the promise is shared while it
2257
+ * is in flight, and every WRITE clears it (see `invalidateScan`) — so a read
2258
+ * after a write always scans again, and a read during a scan joins it.
2259
+ *
2260
+ * Undefined means "no scan in flight and nothing cached".
2261
+ */
2262
+ scanInflight;
2263
+ /** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
2264
+ scanAtMs = 0;
2265
+ /**
1925
2266
  * A RUNTIME account override from the pool's rotation, or undefined.
1926
2267
  *
1927
2268
  * Deliberately separate from {@link accountId}, which is the user's persisted
@@ -1938,6 +2279,8 @@ var WorkBuddyCredentialStore = class {
1938
2279
  this.ownPathExplicit = options.ownPath;
1939
2280
  this.legacyOwnPath = options.legacyOwnPath ?? legacyWorkbuddyOwnAuthPath();
1940
2281
  this.legacyOwnPathExplicit = options.legacyOwnPath;
2282
+ this.vaultDirExplicit = options.vaultDir;
2283
+ this.vaultDirResolved = options.vaultDir ?? (options.region !== void 0 ? workbuddyVaultDir(options.region) : join(resolveDshHome(), "workbuddy-vault"));
1941
2284
  this.authDirs = options.authDirs;
1942
2285
  this.desktopPathOverride = options.desktopPath;
1943
2286
  this.resolveAtRestKey = options.resolveAtRestKey ?? readAtRestKey;
@@ -1974,10 +2317,15 @@ var WorkBuddyCredentialStore = class {
1974
2317
  workbuddyOwnAuthPath("global")
1975
2318
  ];
1976
2319
  }
2320
+ /** The vault directory this store reads and writes (fixed at construction). */
2321
+ vaultDir() {
2322
+ return this.vaultDirResolved;
2323
+ }
1977
2324
  /** Repoint the desktop file or directory; applies on the next read. */
1978
2325
  setDesktopPath(path) {
1979
2326
  this.desktopPathOverride = path;
1980
2327
  this.inflight = void 0;
2328
+ this.invalidateScan();
1981
2329
  }
1982
2330
  /**
1983
2331
  * Select an account by id; tokens stay outside settings.
@@ -1991,6 +2339,7 @@ var WorkBuddyCredentialStore = class {
1991
2339
  selectAccount(accountId) {
1992
2340
  this.accountId = accountId === "" ? void 0 : accountId;
1993
2341
  this.inflight = void 0;
2342
+ this.invalidateScan();
1994
2343
  }
1995
2344
  /** Selected account id, for diagnostics and route assembly. */
1996
2345
  selectedAccountId() {
@@ -2066,13 +2415,42 @@ var WorkBuddyCredentialStore = class {
2066
2415
  * region's accounts are invisible to selection, refresh, and status alike,
2067
2416
  * which is what keeps the two regions' providers from cross-billing.
2068
2417
  */
2418
+ /**
2419
+ * The account list, sharing one scan across concurrent and repeated reads.
2420
+ *
2421
+ * See {@link scanInflight}: the whole performance fix lives in these four
2422
+ * lines. Every caller keeps its OWN semantics (`current`, `accounts`,
2423
+ * `status`, `selectionLost`), because this returns the same array they used
2424
+ * to build themselves — only the filesystem work is shared.
2425
+ *
2426
+ * A failed scan is NOT cached: the rejection clears the slot so the next read
2427
+ * retries, rather than pinning every later caller to one transient failure.
2428
+ */
2069
2429
  async readAll() {
2430
+ const now = Date.now();
2431
+ if (this.scanInflight !== void 0 && now - this.scanAtMs < SCAN_SHARE_WINDOW_MS) return this.scanInflight;
2432
+ this.scanAtMs = now;
2433
+ this.scanInflight = this.scanAll().catch((error) => {
2434
+ this.scanInflight = void 0;
2435
+ throw error;
2436
+ });
2437
+ return this.scanInflight;
2438
+ }
2439
+ /** Drop the shared scan so the next read re-reads the filesystem. */
2440
+ invalidateScan() {
2441
+ this.scanInflight = void 0;
2442
+ this.scanAtMs = 0;
2443
+ }
2444
+ /** The one real scan: read every candidate file, then merge the vault. */
2445
+ async scanAll() {
2070
2446
  const files = await this.candidateFiles();
2447
+ const dismissed = await readDismissed(this.vaultDir());
2071
2448
  const byId = /* @__PURE__ */ new Map();
2072
2449
  for (const file of files) {
2073
2450
  const credential = await readAuthFile(file, this.resolveAtRestKey);
2074
2451
  if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
2075
2452
  const id = workbuddyAccountId(credential);
2453
+ if (dismissed.has(id)) continue;
2076
2454
  const existing = byId.get(id);
2077
2455
  if (existing === void 0) {
2078
2456
  byId.set(id, credential);
@@ -2083,11 +2461,59 @@ var WorkBuddyCredentialStore = class {
2083
2461
  for (const own of await this.readOwns()) {
2084
2462
  if (!this.matchesRegion(own.domain)) continue;
2085
2463
  const id = workbuddyAccountId(own);
2464
+ if (dismissed.has(id)) continue;
2086
2465
  const existing = byId.get(id);
2087
2466
  if (existing === void 0) byId.set(id, own);
2088
2467
  else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
2089
2468
  }
2090
- return [...byId.values()];
2469
+ await this.syncVault(byId, dismissed);
2470
+ return Object.freeze([...byId.values()]);
2471
+ }
2472
+ /**
2473
+ * Merge the scan's credentials into the vault and read the vault back.
2474
+ *
2475
+ * Incremental by contract: a scan that finds nothing new writes nothing.
2476
+ * Per account, the vault entry is written only when the scan's read is
2477
+ * fresher by {@link isFresherVaultEntry} — so a steady-state scan is
2478
+ * mtime-stable and every sync is O(accounts) reads with at most one write
2479
+ * per CHANGED account.
2480
+ */
2481
+ async syncVault(scan, dismissed) {
2482
+ const dir = this.vaultDir();
2483
+ let entries;
2484
+ try {
2485
+ entries = await readVaultEntries(dir);
2486
+ } catch {
2487
+ return;
2488
+ }
2489
+ let dirReady = false;
2490
+ for (const [id, credential] of scan) {
2491
+ if (dismissed.has(id)) continue;
2492
+ const incumbent = entries.get(id);
2493
+ if (incumbent !== void 0 && !shouldReplaceVaultEntry(credential, incumbent)) continue;
2494
+ if (!dirReady) try {
2495
+ await mkdir(dir, { recursive: true });
2496
+ dirReady = true;
2497
+ } catch {
2498
+ return;
2499
+ }
2500
+ await writeVaultEntry(dir, id, credential);
2501
+ entries.set(id, {
2502
+ credential: {
2503
+ ...credential,
2504
+ source: "dsh"
2505
+ },
2506
+ refreshedAtMs: Date.now()
2507
+ });
2508
+ }
2509
+ for (const [id, entry] of entries) {
2510
+ if (!this.matchesRegion(entry.credential.domain)) continue;
2511
+ const existing = scan.get(id);
2512
+ if (dismissed.has(id)) continue;
2513
+ if (existing === void 0) scan.set(id, entry.credential);
2514
+ else if (entry.refreshedFrom !== void 0 && entry.refreshedFrom === authFileName(existing.filePath)) scan.set(id, entry.credential);
2515
+ else if (fileRank(existing.filePath) !== 0 && entry.credential.expiresAtMs > existing.expiresAtMs) scan.set(id, entry.credential);
2516
+ }
2091
2517
  }
2092
2518
  /**
2093
2519
  * Default when no account is explicitly selected: the live sign-in, else the
@@ -2238,6 +2664,142 @@ var WorkBuddyCredentialStore = class {
2238
2664
  }
2239
2665
  }
2240
2666
  /**
2667
+ * Persist one OAuth sign-in into the unified vault.
2668
+ *
2669
+ * The credential is written with `source: 'dsh'` (it never came from a
2670
+ * desktop file) and joins the readable set on the next scan — the vault is
2671
+ * read by `readAll()` like any other source, so no separate discovery path
2672
+ * exists. The write is idempotent per account id: re-adding an account
2673
+ * replaces its entry, which is exactly what re-issuing a login means.
2674
+ *
2675
+ * Returns the account id so the card can offer to select it right away.
2676
+ */
2677
+ async addOAuthAccount(credential) {
2678
+ const id = workbuddyAccountId(credential);
2679
+ const dir = this.vaultDir();
2680
+ await mkdir(dir, { recursive: true });
2681
+ await writeVaultEntry(dir, id, {
2682
+ ...credential,
2683
+ source: "dsh",
2684
+ filePath: join(dir, `${id}.json`)
2685
+ });
2686
+ await this.forgetDismissal(dir, id);
2687
+ this.inflight = void 0;
2688
+ this.invalidateScan();
2689
+ return id;
2690
+ }
2691
+ /**
2692
+ * Drop one id from the dismissal list, if present.
2693
+ *
2694
+ * Silent by design: called on the ADD paths, where a list that cannot be
2695
+ * updated must not fail the action the user asked for. The consequence is the
2696
+ * same as before the fix — the account stays hidden until the list is
2697
+ * repaired — while throwing here would report a failed sign-in that succeeded.
2698
+ */
2699
+ async forgetDismissal(dir, accountId) {
2700
+ try {
2701
+ const dismissed = await readDismissed(dir);
2702
+ if (!dismissed.has(accountId)) return;
2703
+ dismissed.delete(accountId);
2704
+ await writeDismissed(dir, dismissed);
2705
+ } catch {}
2706
+ }
2707
+ /**
2708
+ * Forget EVERY dismissal, so the next read lists the accounts again.
2709
+ *
2710
+ * Called by the manual 「re-scan」 action. Dismissal is "hide until I ask to
2711
+ * scan again", and this is that ask: the user is requesting what the machine
2712
+ * currently has, which includes accounts they hid earlier. Leaving the list
2713
+ * intact would make a removed desktop account unrecoverable from the UI — the
2714
+ * opposite of what the button promises.
2715
+ *
2716
+ * PREFERS deleting the file over writing an empty list: an absent file is the
2717
+ * normal state and keeps the vault directory free of a file that says nothing.
2718
+ * The unlink is not the only way, though, because on Windows removing a file
2719
+ * another process holds open fails with `EPERM`/`EBUSY`/`EACCES` — an antivirus
2720
+ * scanner, OneDrive, or an editor with it open, the same contention the card's
2721
+ * `isFileContentionWriteError` classifies for profile writes and the reason
2722
+ * `docs/WINDOWS.md` keeps a register. POSIX unlinks open files happily, so
2723
+ * without this the promise 「点重新检测账号即可找回」 would be false, silently,
2724
+ * on exactly the platform it matters most on.
2725
+ *
2726
+ * So the unlink falls back to OVERWRITING the list with an empty one through
2727
+ * the same locked atomic write `writeDismissed` uses. For every reader the two
2728
+ * outcomes are identical (`readDismissed` yields no ids either way), so the
2729
+ * user's ask is honoured even when the file itself cannot be removed.
2730
+ *
2731
+ * Returns whether the dismissals are now genuinely gone, VERIFIED by reading
2732
+ * the list back rather than by trusting the write: the caller renders this, so
2733
+ * "it worked" has to mean it. A `false` is the honest answer for a vault that
2734
+ * could neither be unlinked nor rewritten, and the rescan still runs — the
2735
+ * accounts simply stay hidden, which the UI can now say out loud instead of
2736
+ * showing a button that appears to have done nothing.
2737
+ */
2738
+ async clearDismissals() {
2739
+ this.inflight = void 0;
2740
+ this.invalidateScan();
2741
+ const dir = this.vaultDir();
2742
+ const path = join(dir, VAULT_DISMISSED_FILENAME);
2743
+ try {
2744
+ await rm(path, { force: true });
2745
+ } catch {
2746
+ try {
2747
+ await writeDismissed(dir, /* @__PURE__ */ new Set());
2748
+ } catch {
2749
+ return (await readDismissed(dir)).size === 0;
2750
+ }
2751
+ }
2752
+ return (await readDismissed(dir)).size === 0;
2753
+ }
2754
+ /**
2755
+ * Remove ONE account from this region's list, by id.
2756
+ *
2757
+ * The account is DISMISSED, not merely deleted. Deleting the vault entry alone
2758
+ * was undone by the next scan: `syncVault` writes every desktop sign-in it
2759
+ * finds back into the vault (that is how a desktop account becomes selectable)
2760
+ * and `readAll` merges the vault in afterwards, so a removed desktop account
2761
+ * returned on the very next read while `removeAccount` had reported `true`.
2762
+ * Measured before the fix: remove → `true`, then `accounts()` still lists it.
2763
+ *
2764
+ * The desktop app's auth files stay read-only: dismissing hides the account
2765
+ * from this plugin without signing the app out, and that is exactly what the
2766
+ * card says. Re-adding it (a fresh OAuth sign-in or an import) clears the
2767
+ * dismissal, so the account is not lost to a future login.
2768
+ *
2769
+ * Returns whether a stored vault entry was also removed, so the caller can
2770
+ * still tell "removed" from "only a desktop sign-in" — the dismissal happens
2771
+ * either way, because only the user gets to decide an account is dismissed.
2772
+ */
2773
+ async removeAccount(accountId) {
2774
+ if (!VAULT_ACCOUNT_ID_RE.test(accountId)) throw new Error(`workbuddy: refusing to remove '${accountId}': not a vault account id`);
2775
+ const path = join(this.vaultDir(), `${accountId}.json`);
2776
+ const removed = await withFileLock(path, async () => {
2777
+ let existed = true;
2778
+ try {
2779
+ await rm(path, { force: false });
2780
+ } catch (error) {
2781
+ if (!isENOENT(error)) throw error;
2782
+ existed = false;
2783
+ }
2784
+ await rm(`${path}.lock`, { force: false }).catch(() => {});
2785
+ return existed;
2786
+ });
2787
+ if (this.rotatedAccountId === accountId) this.rotatedAccountId = void 0;
2788
+ if (this.accountId === accountId) this.accountId = void 0;
2789
+ const dir = this.vaultDir();
2790
+ try {
2791
+ await mkdir(dir, { recursive: true });
2792
+ const dismissed = await readDismissed(dir);
2793
+ if (!dismissed.has(accountId)) {
2794
+ dismissed.add(accountId);
2795
+ await writeDismissed(dir, dismissed);
2796
+ }
2797
+ } catch {}
2798
+ this.inflight = void 0;
2799
+ this.invalidateScan();
2800
+ return removed;
2801
+ }
2802
+ /**
2241
2803
  * Remove every plugin-owned copy this store could read (per-region file,
2242
2804
  * legacy single file, and their lock siblings); the desktop files are
2243
2805
  * untouched. A region store's logout therefore also clears the legacy
@@ -2249,6 +2811,11 @@ var WorkBuddyCredentialStore = class {
2249
2811
  await rm(path, { force: true });
2250
2812
  await rm(`${path}.lock`, { force: true });
2251
2813
  }
2814
+ await rm(this.vaultDir(), {
2815
+ recursive: true,
2816
+ force: true
2817
+ });
2818
+ this.invalidateScan();
2252
2819
  }
2253
2820
  needsRefresh(credential) {
2254
2821
  if (credential.expiresAtMs <= 0) return true;
@@ -2269,23 +2836,14 @@ var WorkBuddyCredentialStore = class {
2269
2836
  ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
2270
2837
  source: "dsh"
2271
2838
  };
2272
- await this.saveOwn(refreshed);
2839
+ this.invalidateScan();
2840
+ await writeVaultEntry(this.vaultDir(), workbuddyAccountId(credential), refreshed, credential.filePath).catch(() => {});
2273
2841
  return refreshed;
2274
2842
  } catch (error) {
2275
2843
  if (credential.expiresAtMs > Date.now() + 3e4) return credential;
2276
2844
  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
2845
  }
2278
2846
  }
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
2847
  /**
2290
2848
  * Every readable plugin-owned copy, in candidate order; absent or corrupt
2291
2849
  * files are skipped rather than propagated.
@@ -2360,8 +2918,34 @@ var WorkBuddyCredentialStore = class {
2360
2918
  continue;
2361
2919
  }
2362
2920
  }
2921
+ const tried = [...await this.candidateFiles(), ...this.ownCandidates()];
2922
+ try {
2923
+ const names = await readdir(this.vaultDir());
2924
+ for (const name of names.filter(isVaultCredentialFileName)) {
2925
+ const path = join(this.vaultDir(), name);
2926
+ tried.push(path);
2927
+ let text;
2928
+ try {
2929
+ text = await readFile(path, "utf8");
2930
+ } catch (error) {
2931
+ if (!isENOENT(error)) candidates.push({
2932
+ path,
2933
+ source: "dsh",
2934
+ reason: "unreadable",
2935
+ message: error instanceof Error ? error.message : String(error)
2936
+ });
2937
+ continue;
2938
+ }
2939
+ if (parseVaultDocument(text, path) === void 0) candidates.push({
2940
+ path,
2941
+ source: "dsh",
2942
+ reason: "invalid",
2943
+ message: "not a readable vault credential"
2944
+ });
2945
+ }
2946
+ } catch {}
2363
2947
  return {
2364
- tried: [...await this.candidateFiles(), ...this.ownCandidates()],
2948
+ tried,
2365
2949
  failures: candidates
2366
2950
  };
2367
2951
  }
@@ -2855,7 +3439,7 @@ function generationOf(version) {
2855
3439
  //#endregion
2856
3440
  //#region src/version.ts
2857
3441
  /** The npm package version this build was produced from. */
2858
- const WORKBUDDY_CONNECT_VERSION = "4.0.0";
3442
+ const WORKBUDDY_CONNECT_VERSION = "4.3.0";
2859
3443
  //#endregion
2860
3444
  //#region src/host-heartbeat.ts
2861
3445
  /**
@@ -3013,4 +3597,4 @@ async function clearHostHeartbeat() {
3013
3597
  await rm(workbuddyHostHeartbeatPath(), { force: true });
3014
3598
  }
3015
3599
  //#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 };
3600
+ 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 };