dsh-connect-workbuddy 2.0.5 → 2.0.10

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/lib/index.d.ts CHANGED
@@ -31,6 +31,31 @@ interface WorkBuddyCredential {
31
31
  */
32
32
  lastRefreshAtMs?: number;
33
33
  }
34
+ /**
35
+ * Why one probed path did not yield an account.
36
+ *
37
+ * These are safe to surface: they carry paths and cause only, never token
38
+ * material. `encrypted` is the WorkBuddy-specific one — a build whose token
39
+ * fields are encrypted cannot be read at all unless the desktop app itself is
40
+ * present to hand over its at-rest key, so "sign in again" is the wrong advice
41
+ * for it (the user may be perfectly signed in).
42
+ *
43
+ * `wrong-region` is the second WorkBuddy-specific one, and it exists because of
44
+ * a real bug: a region-scoped store filters out every credential that belongs to
45
+ * the other region, so a candidate file holding a perfectly valid sign-in for
46
+ * the other tab was dropped by exactly the same `continue` that drops malformed
47
+ * files. The file then appeared in NO list at all — not an account (filtered by
48
+ * region) and not a failure (it parsed) — so a card could name two paths while
49
+ * its report listed one, and the user's actual, working sign-in was the one
50
+ * made invisible. Reporting it is also the most useful answer we can give: the
51
+ * user is signed in, and merely on the wrong tab.
52
+ */
53
+ interface WorkBuddyCandidateFailure {
54
+ path: string;
55
+ source: 'desktop' | 'dsh';
56
+ reason: 'missing' | 'unreadable' | 'invalid' | 'encrypted' | 'wrong-region';
57
+ message?: string;
58
+ }
34
59
  /** Read-only sign-in summary for status and doctor output. */
35
60
  interface WorkBuddyAuthStatus {
36
61
  state: 'signed-in' | 'signed-out';
@@ -69,13 +94,25 @@ interface WorkBuddyStoreOptions {
69
94
  refresh: (credential: WorkBuddyCredential) => Promise<WorkBuddyRefreshOutcome>;
70
95
  /** Refresh this long before actual expiry; default five minutes. */
71
96
  refreshMarginMs?: number;
97
+ /**
98
+ * Resolves the desktop app's at-rest field key, used to open documents whose
99
+ * token fields are encrypted (Windows builds). Defaults to asking the
100
+ * installed app; injectable so the encrypted path is testable.
101
+ */
102
+ resolveAtRestKey?: () => Promise<Buffer | undefined>;
72
103
  }
73
104
  /** One selectable local account, token-free. */
74
105
  interface WorkBuddyAccountChoice {
75
106
  /** Stable id derived from `uin` (or `uid` when uin is absent). */
76
107
  id: string;
108
+ /**
109
+ * The account's human name, or `''` when the desktop app recorded none.
110
+ *
111
+ * Never an identifier: `uin`/`uid` are opaque numbers and UUIDs, and using one
112
+ * as a display name reads as "the plugin does not know who this is". Callers
113
+ * that render a name choose their own placeholder for the empty case.
114
+ */
77
115
  accountName: string;
78
- uin?: string;
79
116
  domain: string;
80
117
  source: 'desktop' | 'dsh';
81
118
  tokenExpiresAtMs: number;
@@ -119,8 +156,24 @@ declare function defaultDesktopAuthPath(): string | undefined;
119
156
  * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
120
157
  * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
121
158
  * Returns undefined when the document carries no access token.
159
+ *
160
+ * `atRestKey` is required to read documents whose fields are encrypted
161
+ * (see {@link credentialField}); pass the key obtained from
162
+ * `readAtRestKey()` when the plain read reports no token.
122
163
  */
123
- declare function parseWorkBuddyAuth(text: string, filePath: string): WorkBuddyCredential | undefined;
164
+ declare function parseWorkBuddyAuth(text: string, filePath: string, atRestKey?: Buffer): WorkBuddyCredential | undefined;
165
+ /**
166
+ * Whether a document carries any field the reader must decrypt — the token
167
+ * fields or the identity fields — i.e. whether reading it needs the at-rest key
168
+ * at all.
169
+ *
170
+ * Deciding this BEFORE asking the app for its key keeps the plain case (macOS
171
+ * and older Windows builds, and every plugin-owned copy) from paying for a
172
+ * child process on every credential read. Identity fields are included because
173
+ * a future build could encrypt the display name while leaving tokens plain;
174
+ * gating on tokens alone would then silently drop the name again.
175
+ */
176
+ declare function hasEncryptedCredentialFields(text: string): boolean;
124
177
  /**
125
178
  * Filename of a path regardless of the host separator: Windows paths use `\`
126
179
  * and this helper must keep working when a Windows path is compared on a
@@ -150,6 +203,7 @@ declare class WorkBuddyCredentialStore {
150
203
  private readonly legacyOwnPath;
151
204
  private readonly legacyOwnPathExplicit;
152
205
  private readonly authDirs;
206
+ private readonly resolveAtRestKey;
153
207
  private desktopPathOverride;
154
208
  private accountId;
155
209
  private inflight;
@@ -262,6 +316,16 @@ declare class WorkBuddyCredentialStore {
262
316
  selectionLost(): Promise<boolean>;
263
317
  /** The freshest stored credential for the current selection, no refresh. */
264
318
  current(): Promise<WorkBuddyCredential | undefined>;
319
+ /**
320
+ * One account's stored credential by id, WITHOUT consulting or changing the
321
+ * current selection.
322
+ *
323
+ * Exists so a recovery probe can test whether some OTHER local account is
324
+ * still accepted upstream while a rejected one stays selected: mutating the
325
+ * live selection to find that out would be exactly the silent account switch
326
+ * this store refuses to perform (it would bill the wrong account mid-probe).
327
+ */
328
+ credentialFor(accountId: string): Promise<WorkBuddyCredential | undefined>;
265
329
  /** The credential to send upstream: {@link current}, refreshed on demand. */
266
330
  resolve(): Promise<WorkBuddyCredential>;
267
331
  /** Read-only sign-in summary; never refreshes and never throws. */
@@ -284,6 +348,21 @@ declare class WorkBuddyCredentialStore {
284
348
  private readOwns;
285
349
  /** Whether any candidate file exists as a regular file; diagnostics only. */
286
350
  desktopFilePresent(): Promise<boolean>;
351
+ /**
352
+ * Which paths were probed and why each one yielded no credential.
353
+ *
354
+ * Read-only and token-free: it exists so a signed-out card can explain
355
+ * itself. A bare "not signed in" is undiagnosable on a machine whose layout
356
+ * differs from the ones this plugin was written against — and on Windows it
357
+ * is actively misleading, because encrypted token fields need the desktop app
358
+ * present to be read at all. Only paths this store actually consults are
359
+ * reported, and only files that failed: one healthy sibling would make the
360
+ * whole list noise.
361
+ */
362
+ diagnose(): Promise<{
363
+ tried: string[];
364
+ failures: WorkBuddyCandidateFailure[];
365
+ }>;
287
366
  }
288
367
  //#endregion
289
368
  //#region src/upstream.d.ts
@@ -395,6 +474,28 @@ declare function regionOf(domain: string): WorkBuddyRegion;
395
474
  * `tool_choice` (the upstream's field is a string; object forms return 400).
396
475
  */
397
476
  declare function prepareChatBody(source: string): string;
477
+ /** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
478
+ declare const CREDENTIAL_REJECTED_CODE = "WORKBUDDY_CREDENTIAL_REJECTED";
479
+ /**
480
+ * The upstream refused the CREDENTIAL itself rather than failing the request.
481
+ *
482
+ * This is a distinct, actionable class: the token is not usable and no retry
483
+ * with the same token will help. Callers use it to tell the user what to do
484
+ * about it (switch accounts, or sign in again) instead of showing a raw HTTP
485
+ * error, and the card must never confuse it with a transient upstream fault.
486
+ *
487
+ * Identified by {@link CREDENTIAL_REJECTED_CODE} rather than `instanceof`, so
488
+ * the check keeps working when the caller and the thrower end up in different
489
+ * module instances (bundled host half vs. a test's source import).
490
+ */
491
+ declare class WorkBuddyCredentialRejectedError extends Error {
492
+ readonly code = "WORKBUDDY_CREDENTIAL_REJECTED";
493
+ /** HTTP status the upstream answered with (401 or 403). */
494
+ readonly status: number;
495
+ constructor(status: number);
496
+ }
497
+ /** Whether an error reports that the upstream refused the credential itself. */
498
+ declare function isCredentialRejectedError(value: unknown): value is WorkBuddyCredentialRejectedError;
398
499
  /**
399
500
  * Parse the upstream's `credits` string into a multiplier.
400
501
  *
@@ -479,17 +580,79 @@ declare function fallbackModelsFor(region: 'cn' | 'global'): readonly WorkBuddyM
479
580
  */
480
581
  type WorkBuddyContextBudget = number;
481
582
  declare function deriveCatalog(catalog: readonly WorkBuddyModelInfo[], enabled: ReadonlySet<string>, budgets?: Readonly<Record<string, WorkBuddyContextBudget | undefined>>): WorkBuddyModelInfo[];
482
- /** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
583
+ /**
584
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
585
+ *
586
+ * The static fallback exists so an OFFLINE upstream never leaves a provider
587
+ * empty — but "offline" presumes the region is usable at all. A region with no
588
+ * local sign-in cannot serve one single request, so seeding it with a roster
589
+ * puts models in the picker that are guaranteed to 401 (issue #12). The host
590
+ * distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
591
+ */
483
592
  declare class WorkBuddyCatalog {
484
593
  private models;
594
+ /**
595
+ * Whether this region has any local sign-in. Defaults to `true` — the
596
+ * permissive direction — so a scan that has not run yet (or failed) keeps
597
+ * serving the fallback rather than blanking a region that may be fine.
598
+ */
599
+ private usable;
600
+ /**
601
+ * Whether the user has switched this region's provider ON. Opt-out: defaults
602
+ * to `true`, so a config predating this switch (and the pre-region-split flat
603
+ * fields, which never carry `enabled`) keeps both providers running. Set to
604
+ * `false` by `setRegionEnabled` to withdraw the region from DSH's model
605
+ * picker entirely (its adapter route and configurable-provider entry are
606
+ * pulled by the Host via `AdapterRegistrationHandle.replace([])` /
607
+ * `DirectoryRegistrationHandle.replace([])`). This is a USER choice, distinct
608
+ * from {@link usable}: a region the user switched off keeps its catalog,
609
+ * accounts, and model picks intact and reappears the moment they re-check it.
610
+ */
611
+ private enabled;
485
612
  /**
486
613
  * @param region Seeds the static fallback for this region; each region's
487
614
  * provider must never serve the other region's roster before its first
488
615
  * live refresh lands.
489
616
  */
490
617
  constructor(region?: 'cn' | 'global');
491
- /** Current entries; the fallback list until the upstream answer lands. */
618
+ /**
619
+ * Current entries; the fallback list until the upstream answer lands.
620
+ *
621
+ * Empty while this region has no local sign-in OR the user has switched it
622
+ * off: the provider stays registered (its row still hosts the settings card
623
+ * and the account picker), but it advertises nothing to pick. DSH drops empty
624
+ * provider groups from the picker (`buildModelCatalog` filters
625
+ * `models.length > 0`), so the group disappears exactly when it would be pure
626
+ * noise. The two empty causes are independent: `usable` is the automatic
627
+ * "no account" gate (issue #12), `enabled` is the user's explicit on/off.
628
+ */
492
629
  current(): readonly WorkBuddyModelInfo[];
630
+ /** Whether this region currently advertises any model. */
631
+ isRegionUsable(): boolean;
632
+ /** Whether the user has this region's provider switched on. */
633
+ isRegionEnabled(): boolean;
634
+ /**
635
+ * Record whether this region has a local sign-in.
636
+ *
637
+ * Separate from {@link set} on purpose: `set()` carries a live upstream
638
+ * answer and must never be empty, while "this region has no account" is a
639
+ * legitimate empty state that must survive across refreshes. Keeping them
640
+ * apart is what lets the non-empty guard stay strict.
641
+ *
642
+ * @returns whether the value changed, so the caller can skip invalidating
643
+ * its adapter snapshots when nothing moved.
644
+ */
645
+ setRegionUsable(usable: boolean): boolean;
646
+ /**
647
+ * Record the user's explicit on/off choice for this region's provider.
648
+ *
649
+ * Distinct from {@link setRegionUsable}: that is the automatic "no local
650
+ * account" gate, while this is a deliberate user action (issue #11-style
651
+ * region switch). The Host reads the same `enabled` flag through
652
+ * `regionStateOf(config, region).enabled !== false`, so the two halves never
653
+ * disagree. Returns whether the value changed.
654
+ */
655
+ setRegionEnabled(enabled: boolean): boolean;
493
656
  /** Replace the list; callers invalidate their adapter snapshot after this. */
494
657
  set(models: readonly WorkBuddyModelInfo[]): void;
495
658
  }
@@ -586,6 +749,170 @@ declare function workBuddyThinkingLevelMap(info: WorkBuddyModelInfo): WorkBuddyT
586
749
  */
587
750
  declare function createWorkBuddyAdapter(options: WorkBuddyAdapterOptions): WorkBuddyAdapter;
588
751
  //#endregion
752
+ //#region src/at-rest.d.ts
753
+ /**
754
+ * WorkBuddy desktop "at-rest" credential decryption.
755
+ *
756
+ * The WorkBuddy desktop app (2.x, Windows builds first) no longer stores
757
+ * `auth.accessToken` / `auth.refreshToken` as plain strings. It now writes a
758
+ * field wrapper:
759
+ *
760
+ * { "$wbEncrypted": 1, "envelope": "<base64 of a JSON envelope>" }
761
+ *
762
+ * where the envelope is `{suite, keyId, nonce, authTag, ciphertext}` for
763
+ * AES-256-GCM with a 12-byte nonce and a 16-byte tag. The authenticated
764
+ * additional data is a length-prefixed transcript over the scheme, suite,
765
+ * keyId and framing, so the ciphertext can only be opened for the exact field
766
+ * shape it was sealed for.
767
+ *
768
+ * The field key itself is NOT a user secret: it is a build-time constant
769
+ * compiled into the app's own Electron native module
770
+ * (`electron_browser_workbuddy_storage`). The app fetches it through
771
+ * `loggerGet()` and hashes the returned base64 STRING (not the decoded bytes)
772
+ * to obtain the 32-byte key; `keyId` is the first 16 hex characters of that
773
+ * key's SHA-256.
774
+ *
775
+ * This module re-derives the same key by asking the installed app for the same
776
+ * payload, and caches it in memory for the process lifetime. Nothing is ever
777
+ * written to disk, and the payload is never logged.
778
+ *
779
+ * @module dsh-connect-workbuddy/at-rest
780
+ */
781
+ /** A `{$wbEncrypted:1,envelope}` field wrapper, the only shape this module opens. */
782
+ interface WorkBuddyEncryptedField {
783
+ $wbEncrypted: 1;
784
+ envelope: string;
785
+ }
786
+ /** Env override pointing at the WorkBuddy desktop executable. */
787
+ declare const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
788
+ /** Whether a value is the app's encrypted-field wrapper. */
789
+ declare function isEncryptedFieldWrapper(value: unknown): value is WorkBuddyEncryptedField;
790
+ /**
791
+ * The at-rest key id for a derived 32-byte key: the first 16 hex characters of
792
+ * its SHA-256. This is what the envelope's `keyId` is checked against, so a
793
+ * mismatched key fails loudly instead of returning garbage.
794
+ */
795
+ declare function deriveAtRestKeyId(key: Buffer): string;
796
+ /**
797
+ * Derive the 32-byte field key from the app's key payload JSON.
798
+ *
799
+ * The app hashes the payload's base64 STRING — not its decoded bytes — so the
800
+ * same spelling is required here; hashing the decoded secret would produce a
801
+ * different key and every field would fail to open.
802
+ */
803
+ declare function deriveAtRestKey(payloadJson: string): Buffer;
804
+ /**
805
+ * Open one encrypted field with a derived key and return its plaintext.
806
+ *
807
+ * Throws when the envelope is malformed, belongs to another key, or fails
808
+ * authentication — a GCM tag mismatch is the signal that the transcript or the
809
+ * key is wrong, and it must never degrade into a truncated token.
810
+ */
811
+ declare function openEncryptedField(field: WorkBuddyEncryptedField, key: Buffer): string;
812
+ /**
813
+ * Candidate paths of the WorkBuddy desktop executable, in probe order.
814
+ *
815
+ * The Windows build is the one that encrypts credentials, so Windows leads;
816
+ * the macOS bundle is listed because the same native module ships there and a
817
+ * future build may enable the policy, and `undefined` entries (an unset env
818
+ * variable) are dropped.
819
+ */
820
+ declare function workbuddyAppExecutableCandidates(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string[];
821
+ /**
822
+ * The first candidate that exists as a file, or undefined when the desktop app
823
+ * is not installed where this platform expects it.
824
+ */
825
+ declare function findWorkbuddyAppExecutable(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string | undefined;
826
+ /**
827
+ * Ask the installed desktop app for its key payload by running its own binary
828
+ * as plain Node (`ELECTRON_RUN_AS_NODE`) and calling the native binding.
829
+ *
830
+ * The binding is the app's own public surface for this value, so the plugin
831
+ * never has to carry a copy of a build-specific constant: it asks the very
832
+ * build that wrote the file. The child is given no stdin and a hard timeout,
833
+ * and its stdout is the only thing read.
834
+ */
835
+ declare function fetchAtRestKeyPayload(executable: string): Promise<string>;
836
+ /**
837
+ * The desktop app's at-rest field key, or undefined when it cannot be obtained
838
+ * (app not installed, an older build without the native module, or a future
839
+ * build that rotates the payload). Cached after the first success so the app is
840
+ * spawned at most once per process; a failure is retried on the next call,
841
+ * because the user may install or start the app between reads.
842
+ */
843
+ declare function readAtRestKey(): Promise<Buffer | undefined>;
844
+ /** Drop the cached key; tests and diagnostics only. */
845
+ declare function clearAtRestKeyCache(): void;
846
+ //#endregion
847
+ //#region src/credential-recovery.d.ts
848
+ /** What the user can do about a refused credential. */
849
+ interface WorkBuddyCredentialRecovery {
850
+ /**
851
+ * Another local account answered the upstream during this recovery, so
852
+ * switching to it is a fix that has been verified — not a guess.
853
+ */
854
+ usableAccount?: {
855
+ accountId: string;
856
+ accountName: string;
857
+ };
858
+ /**
859
+ * Whether signing in again is the only remaining fix: no other local account
860
+ * exists at all, so there is nothing to switch to.
861
+ */
862
+ reloginRequired: boolean;
863
+ }
864
+ /** One account as the recovery path needs to see it. */
865
+ interface WorkBuddyRecoveryCandidate {
866
+ id: string;
867
+ accountName: string;
868
+ }
869
+ /** Reads one account's credential for probing, without changing the selection. */
870
+ interface WorkBuddyProbeStore {
871
+ accounts(): Promise<readonly WorkBuddyRecoveryCandidate[]>;
872
+ credentialFor(accountId: string): Promise<WorkBuddyCredential | undefined>;
873
+ }
874
+ /** Constructor options for {@link createAccountUsabilityProbe}. */
875
+ interface WorkBuddyUsabilityProbeOptions {
876
+ /** The credential store for one region (its selection is never touched). */
877
+ store(region: WorkBuddyRegion): WorkBuddyProbeStore;
878
+ /** The upstream client; only its cheap read-only calls are used. */
879
+ client: Pick<WorkBuddyUpstreamClient, 'fetchCheckinStatus'>;
880
+ /**
881
+ * How long a probe verdict is reused. The card polls the usage route, so
882
+ * without this a broken selection would re-probe every account on every poll.
883
+ */
884
+ ttlMs?: number;
885
+ /** Injectable clock, for tests. */
886
+ now?: () => number;
887
+ }
888
+ /**
889
+ * Cached "is this account still accepted upstream?" probe.
890
+ *
891
+ * The cache key includes the credential's own `lastRefreshAtMs`, so a fresh
892
+ * sign-in (which rewrites the file's issuance time) invalidates a stale verdict
893
+ * automatically — a rejection recorded before the user signed in again cannot
894
+ * keep labelling the account unusable afterwards, and no explicit invalidation
895
+ * is needed.
896
+ */
897
+ declare function createAccountUsabilityProbe(options: WorkBuddyUsabilityProbeOptions): (region: WorkBuddyRegion, account: WorkBuddyRecoveryCandidate) => Promise<boolean>;
898
+ /**
899
+ * Decide what to tell the user after the upstream refused the selected
900
+ * credential: switch to a verified-usable account, or sign in again.
901
+ *
902
+ * The two answers are mutually exclusive by construction. `reloginRequired` is
903
+ * set ONLY when there is no other local account to switch to — never as a
904
+ * fallback for "the probe did not verify anything", because that would repeat
905
+ * the misdirection this module exists to remove. When other accounts exist but
906
+ * none was verified, both fields stay empty and the card says so honestly.
907
+ */
908
+ declare function resolveCredentialRecovery(options: {
909
+ region: WorkBuddyRegion;
910
+ store: WorkBuddyProbeStore;
911
+ rejectedAccountId?: string;
912
+ /** Verifies whether one account is still accepted; see the probe factory. */
913
+ probe: (region: WorkBuddyRegion, account: WorkBuddyRecoveryCandidate) => Promise<boolean>;
914
+ }): Promise<WorkBuddyCredentialRecovery>;
915
+ //#endregion
589
916
  //#region src/host-heartbeat.d.ts
590
917
  /**
591
918
  * Host-side heartbeat: a small JSON file written under `$DSH_HOME` once the
@@ -689,6 +1016,36 @@ declare function withWorkBuddyRegion(path: string, region: WorkBuddyWebRegion):
689
1016
  * answer 400 instead of guessing.
690
1017
  */
691
1018
  declare function regionOfStatusUrl(url: string): WorkBuddyWebRegion | undefined;
1019
+ /**
1020
+ * Whether one region's provider is switched on. Opt-out semantics: only an
1021
+ * explicit `false` disables it, so a config written before this switch existed
1022
+ * (and the pre-region-split flat fields, which never carry `enabled`) keep both
1023
+ * providers running exactly as before. The Host reads the same rule through
1024
+ * `regionStateOf`, so card and Host can never disagree about a region's state.
1025
+ *
1026
+ * `value` may be the whole settings section or the `regions` map (see
1027
+ * {@link regionsMapOf}).
1028
+ */
1029
+ declare function regionEnabledOf(value: unknown, region: WorkBuddyWebRegion): boolean;
1030
+ /** Build the next `regions` settings value for a signed-in tab's save. */
1031
+ declare function nextRegionSlots<Slot extends object>(regions: unknown, region: WorkBuddyWebRegion, slot: Slot): Record<string, unknown>;
1032
+ /**
1033
+ * Build the next `regions` settings value for a provider on/off toggle. ONLY
1034
+ * the target region's `enabled` flag changes: every other field of that slot
1035
+ * (its directory, selection, image opt-ins, context budgets) and every other
1036
+ * region's slot are carried over verbatim, so switching a provider off never
1037
+ * discards the user's model picks and switching it back on restores them.
1038
+ *
1039
+ * This is deliberately separate from {@link nextRegionSlots}: that helper
1040
+ * writes a whole slot from a signed-in tab's draft, while this one must work
1041
+ * for a region that is signed OUT — which is precisely the region a user wants
1042
+ * to switch off (no international install, no international account).
1043
+ *
1044
+ * `value` may be the whole settings section or the `regions` map; the RETURN
1045
+ * value is always the `regions` map, i.e. exactly what `settingsScope.set(
1046
+ * 'regions', ...)` needs.
1047
+ */
1048
+ declare function nextRegionEnabled(value: unknown, region: WorkBuddyWebRegion, enabled: boolean): Record<string, unknown>;
692
1049
  /** One credit package as the upstream returns it, node-free. */
693
1050
  interface WorkBuddyWebCreditPackage {
694
1051
  packageName: string;
@@ -723,6 +1080,30 @@ interface WorkBuddyWebCheckin {
723
1080
  streakBonusCredit: number;
724
1081
  claimButtonText?: string;
725
1082
  }
1083
+ /**
1084
+ * What the user can do about a credential the upstream refused.
1085
+ *
1086
+ * Two different fixes hide behind one 401, and naming the wrong one is worse
1087
+ * than saying nothing: with a stale account SELECTED but a healthy sign-in
1088
+ * sitting right there, "sign in again" sends the user to re-authenticate — which
1089
+ * changes nothing, because the credentials were never the problem.
1090
+ */
1091
+ interface WorkBuddyWebRecovery {
1092
+ /**
1093
+ * Another local account answered the upstream during recovery, so switching
1094
+ * to it is a VERIFIED fix rather than a suggestion.
1095
+ */
1096
+ usableAccount?: {
1097
+ accountId: string;
1098
+ accountName: string;
1099
+ };
1100
+ /**
1101
+ * Signing in again is the only remaining fix: this region has no other local
1102
+ * account to switch to. Set only in that case — never as a fallback for
1103
+ * "nothing could be verified".
1104
+ */
1105
+ reloginRequired: boolean;
1106
+ }
726
1107
  /** Editable WorkBuddy model row rendered by the plugin-owned settings card. */
727
1108
  interface WorkBuddyWebModel {
728
1109
  id: string;
@@ -753,14 +1134,39 @@ declare function toPersistedWorkBuddyModel(model: WorkBuddyWebModel): Omit<WorkB
753
1134
  /** One selectable local account, token-free. */
754
1135
  interface WorkBuddyWebAccount {
755
1136
  id: string;
1137
+ /**
1138
+ * The account's human name, or `''` when the desktop app recorded none.
1139
+ * The card renders its own placeholder for the empty case.
1140
+ */
756
1141
  accountName: string;
757
- uin?: string;
758
1142
  domain: string;
759
1143
  source: 'desktop' | 'dsh';
760
1144
  tokenExpiresAtMs: number;
761
1145
  selected: boolean;
762
1146
  }
763
1147
  type WorkBuddyWebPackage = WorkBuddyWebCreditPackage;
1148
+ /**
1149
+ * One probed candidate path and why it yielded no account.
1150
+ *
1151
+ * Safe to send to the browser: a path, its source, and a cause. No token
1152
+ * material, and no content read out of the files beyond an error string.
1153
+ *
1154
+ * `encrypted` exists because WorkBuddy (unlike a plain JSON-token app) can
1155
+ * refuse a correctly signed-in user: Windows builds encrypt the token fields,
1156
+ * and opening them needs the desktop app present to hand over its at-rest key.
1157
+ * Telling that user to "sign in again" is wrong — they already are.
1158
+ *
1159
+ * `wrong-region` is the other WorkBuddy-specific one: the file holds a valid
1160
+ * sign-in for the OTHER tab's region, so this tab filters it out of the account
1161
+ * list. Without this entry the file was reported nowhere at all, which both
1162
+ * undercounted "paths checked" and hid the user's real sign-in.
1163
+ */
1164
+ interface WorkBuddyWebSearchPath {
1165
+ path: string;
1166
+ source: 'desktop' | 'dsh';
1167
+ reason: 'missing' | 'unreadable' | 'invalid' | 'encrypted' | 'wrong-region';
1168
+ message?: string;
1169
+ }
764
1170
  /**
765
1171
  * Region of the signed-in credential: the CN app (`codebuddy.cn` /
766
1172
  * `workbuddy.cn`) or the international WorkBuddy AI app (`workbuddy.ai`).
@@ -786,19 +1192,36 @@ type WorkBuddyWebUsage =
786
1192
  {
787
1193
  status: 'signed-out';
788
1194
  accounts: readonly WorkBuddyWebAccount[];
1195
+ /** Whether this region's provider is currently offered to DSH. */
1196
+ enabled?: boolean;
789
1197
  message?: string;
790
1198
  /** The persisted account id matches no local account. */
791
1199
  selectionLost?: boolean;
792
1200
  /** A saved per-region choice is in effect (false = following the app). */
793
1201
  selectionExplicit: boolean;
1202
+ /**
1203
+ * The paths this region's store probed, and why each yielded nothing.
1204
+ *
1205
+ * Present only on the "nothing was found at all" branch — it is the
1206
+ * explanation for a state that is otherwise a dead end. A missing candidate
1207
+ * (`missing`) is normal noise on any machine and the card filters those out
1208
+ * of its default view; the interesting ones are `unreadable`, `invalid`,
1209
+ * and especially `encrypted`.
1210
+ */
1211
+ searched?: readonly WorkBuddyWebSearchPath[];
794
1212
  } | {
795
1213
  status: 'signed-in';
796
1214
  accountId: string;
1215
+ /**
1216
+ * The account's human name, or `''` when the desktop app recorded none.
1217
+ * Never an identifier — see {@link WorkBuddyWebAccount.accountName}.
1218
+ */
797
1219
  accountName: string;
798
- uin?: string;
799
1220
  domain?: string;
800
1221
  /** Which per-region model directory and selection this account owns. */
801
1222
  region: WorkBuddyWebRegion;
1223
+ /** Whether this region's provider is currently offered to DSH. */
1224
+ enabled?: boolean;
802
1225
  source?: 'desktop' | 'dsh';
803
1226
  tokenExpiresAtMs: number;
804
1227
  /** A saved per-region choice is in effect (false = following the app). */
@@ -811,6 +1234,14 @@ type WorkBuddyWebUsage =
811
1234
  creditsError?: string;
812
1235
  checkin?: WorkBuddyWebCheckin;
813
1236
  checkinError?: string;
1237
+ /**
1238
+ * The upstream refused the credential itself (a 401/403), as opposed to a
1239
+ * transient upstream fault. Distinguishes "your token is not usable" from
1240
+ * "the request failed", which need opposite advice.
1241
+ */
1242
+ credentialRejected?: boolean;
1243
+ /** Present only alongside {@link credentialRejected}. */
1244
+ recovery?: WorkBuddyWebRecovery;
814
1245
  } | {
815
1246
  status: 'error';
816
1247
  message: string;
@@ -837,6 +1268,31 @@ interface WorkBuddyStatusRouteOptions {
837
1268
  contextBudgets(region: WorkBuddyRegion): Readonly<Record<string, number | undefined>>;
838
1269
  /** Re-read the live catalog of one region from the upstream. */
839
1270
  discoverModels?(region: WorkBuddyRegion, signal?: AbortSignal): Promise<readonly WorkBuddyModelInfo[]>;
1271
+ /**
1272
+ * Whether the requested region's provider is currently offered to DSH. The
1273
+ * card renders this as the tab's on/off checkbox, so the switch reflects the
1274
+ * committed settings value rather than a local guess.
1275
+ */
1276
+ regionEnabled(region: WorkBuddyRegion): boolean;
1277
+ /**
1278
+ * Report whether a region still has at least one local sign-in.
1279
+ *
1280
+ * The card is where sign-in state actually changes under the plugin's nose
1281
+ * (the user signs in to the desktop app, then presses "detect accounts
1282
+ * again"), so the routes are the earliest honest place to notice. Both
1283
+ * callers already read `store.accounts()` for their own answer, so this adds
1284
+ * no scan of its own.
1285
+ */
1286
+ regionUsable?(region: WorkBuddyRegion, usable: boolean): void;
1287
+ /**
1288
+ * Verify whether one local account is still accepted by the upstream.
1289
+ *
1290
+ * Consulted ONLY after the selected credential has been refused, to tell
1291
+ * "switch accounts" apart from "sign in again". Left undefined, nothing is
1292
+ * claimed usable and the card falls back to the re-login advice that needs no
1293
+ * probe.
1294
+ */
1295
+ accountUsable?(region: WorkBuddyRegion, account: WorkBuddyRecoveryCandidate): Promise<boolean>;
840
1296
  }
841
1297
  /**
842
1298
  * Assemble one region's card document. `region` is the tab the card is on;
@@ -862,6 +1318,15 @@ declare const inject: string[];
862
1318
  declare const WORKBUDDY_SETTINGS_NS: SettingsNamespace;
863
1319
  /** One region's model directory and the user's selection within it. */
864
1320
  interface WorkBuddyRegionState {
1321
+ /**
1322
+ * Whether this region's provider is switched on. Opt-out: only an explicit
1323
+ * `false` disables it, so a config predating this switch keeps both providers
1324
+ * running. A disabled region is fully withdrawn from the harness — its adapter
1325
+ * route and its configurable-provider entry both hold zero routes, so it
1326
+ * disappears from DSH's model picker instead of lingering as an unselectable
1327
+ * row (see `syncRegionRegistration`).
1328
+ */
1329
+ enabled?: boolean;
865
1330
  /** The last-refreshed directory for this region; what the card displays. */
866
1331
  lastCatalog?: WorkBuddyModelInfo[];
867
1332
  /** The user's selection in this region, as model ids. */
@@ -918,6 +1383,15 @@ declare const Config: z<Config>;
918
1383
  * picks.
919
1384
  */
920
1385
  declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBuddyRegionState;
1386
+ /**
1387
+ * Whether one region's provider is switched on. Opt-out semantics: only an
1388
+ * explicit `false` disables it, so every config written before this switch
1389
+ * existed — including the pre-region-split flat fields, which never carry
1390
+ * `enabled` — keeps both providers running exactly as before. The card reads the
1391
+ * same rule through `regionEnabledOf`, so the two halves can never disagree
1392
+ * about a region's state.
1393
+ */
1394
+ declare function regionEnabled(config: Config, region: WorkBuddyRegion): boolean;
921
1395
  /**
922
1396
  * The account id a region should select, or `undefined` for the documented
923
1397
  * default: follow whatever the WorkBuddy app is currently signed in as.
@@ -968,4 +1442,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
968
1442
  */
969
1443
  declare function apply(ctx: Context, config: Config): void;
970
1444
  //#endregion
971
- export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyReasoning, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionCleared, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1445
+ export { CREDENTIAL_REJECTED_CODE, Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebSearchPath, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, inject, isCredentialRejectedError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };