dsh-connect-workbuddy 3.6.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.
- package/CHANGELOG.md +78 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/docs/assets/dsh-connect-workbuddy-composer-credits.png +0 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1754 -27
- package/lib/{host-heartbeat-Cg2ahl1n.js → host-heartbeat-C4TXu8ew.js} +367 -25
- package/lib/index.d.ts +263 -2
- package/lib/index.js +895 -10
- package/package.json +1 -1
- package/screenshots.json +2 -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,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
|
-
/**
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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.
|
|
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
|
|
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 = "
|
|
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 {
|
|
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 };
|