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.
- package/CHANGELOG.md +88 -0
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/docs/WINDOWS.md +38 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1130 -15
- package/lib/{host-heartbeat-vvVjdY_q.js → host-heartbeat-jNlsUMZO.js} +610 -26
- package/lib/index.d.ts +297 -2
- package/lib/index.js +794 -11
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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 };
|