@bitkyc08/opencodex 2.91.0 → 2.92.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.
Files changed (148) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
  3. package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
  4. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  5. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  8. package/package.json +4 -3
  9. package/src/adapters/google-vertex-endpoint.ts +9 -1
  10. package/src/adapters/google-vertex-key-project.ts +223 -0
  11. package/src/adapters/google.ts +13 -3
  12. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  13. package/src/adapters/openai-chat.ts +2 -2
  14. package/src/adapters/openai-responses/request-strips.ts +60 -0
  15. package/src/adapters/registry.ts +5 -0
  16. package/src/adapters/workbuddy-schema.ts +61 -0
  17. package/src/adapters/workbuddy.ts +68 -0
  18. package/src/bridge/response-json.ts +1 -1
  19. package/src/bridge/sse.ts +1 -1
  20. package/src/chat/outbound.ts +11 -4
  21. package/src/cli/capabilities-agents-routing.ts +3 -3
  22. package/src/cli/codex-cli-update.ts +8 -0
  23. package/src/cli/combo-result.ts +6 -0
  24. package/src/cli/combo-stats.ts +3 -0
  25. package/src/cli/combo.ts +66 -42
  26. package/src/cli/launcher-context.ts +49 -2
  27. package/src/cli/link.ts +45 -32
  28. package/src/cli/provider.ts +2 -1
  29. package/src/cli/usage-compare.ts +68 -12
  30. package/src/client/link-ingress.ts +1 -1
  31. package/src/client/link-readiness.ts +97 -0
  32. package/src/client/link-relay-transport.ts +79 -11
  33. package/src/client/link-relay.ts +74 -15
  34. package/src/client/link-tunnel.ts +45 -18
  35. package/src/client/machine-listener.ts +20 -2
  36. package/src/client/runtime.ts +20 -2
  37. package/src/codex/account-store.ts +17 -3
  38. package/src/codex/app-server-restart-service.ts +6 -5
  39. package/src/codex/catalog/effort.ts +9 -2
  40. package/src/codex/catalog/gather-capture.ts +4 -2
  41. package/src/codex/catalog/provider-models.ts +14 -8
  42. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  43. package/src/codex/cli-install-provenance.ts +418 -0
  44. package/src/codex/cli-installation-targets.ts +5 -1
  45. package/src/codex/cli-update-plan.ts +43 -16
  46. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  47. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  48. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  49. package/src/codex/pool-refresh-backoff.ts +3 -1
  50. package/src/codex/user-identity.ts +35 -6
  51. package/src/codex/windows-current-sid.ts +162 -0
  52. package/src/combos/jev-quota-activation.ts +5 -1
  53. package/src/combos/jev-quota-route.ts +1 -1
  54. package/src/combos/jev-quota.ts +9 -1
  55. package/src/combos/jev-session-pin-config.ts +51 -0
  56. package/src/combos/jev.ts +6 -0
  57. package/src/combos/types.ts +19 -0
  58. package/src/config/provider-validation.ts +7 -0
  59. package/src/config/schema/leaf-validators.ts +1 -0
  60. package/src/generated/compatibility-version.json +194 -122
  61. package/src/lib/decision-quota-sink.ts +10 -0
  62. package/src/lib/translator-budget.ts +4 -4
  63. package/src/lib/upstream-retry.ts +4 -1
  64. package/src/link/diagnostics.ts +193 -0
  65. package/src/link/relay-auth.ts +6 -0
  66. package/src/link/status-projection.ts +39 -0
  67. package/src/link/supervisor.ts +45 -12
  68. package/src/link/tunnel-history.ts +109 -0
  69. package/src/link/tunnel-state.ts +80 -17
  70. package/src/messaging/remote-files.ts +24 -1
  71. package/src/oauth/generic-account-failover.ts +91 -7
  72. package/src/oauth/index.ts +15 -0
  73. package/src/oauth/model-discovery-auth.ts +5 -0
  74. package/src/oauth/store.ts +4 -1
  75. package/src/oauth/token-guardian.ts +7 -0
  76. package/src/oauth/types.ts +9 -0
  77. package/src/oauth/workbuddy.ts +139 -0
  78. package/src/providers/antigravity-quota-identity.ts +37 -0
  79. package/src/providers/base-url-choices.ts +8 -5
  80. package/src/providers/derive.ts +23 -3
  81. package/src/providers/model-discovery.ts +2 -0
  82. package/src/providers/model-rename-fields.ts +1 -0
  83. package/src/providers/quota/account-cache.ts +3 -2
  84. package/src/providers/quota/antigravity.ts +25 -14
  85. package/src/providers/quota/report-cache.ts +13 -4
  86. package/src/providers/quota/vendor-probes-key.ts +60 -0
  87. package/src/providers/quota/workbuddy.ts +83 -0
  88. package/src/providers/quota-decision-antigravity.ts +64 -0
  89. package/src/providers/quota-decision-publication.ts +11 -0
  90. package/src/providers/quota-decision-snapshot.ts +21 -4
  91. package/src/providers/quota-types.ts +2 -0
  92. package/src/providers/quota.ts +3 -1
  93. package/src/providers/registry/entries-core.ts +9 -0
  94. package/src/providers/registry/entries-extended.ts +20 -7
  95. package/src/providers/registry/model-ids.ts +3 -0
  96. package/src/providers/registry/model-seeds.ts +27 -28
  97. package/src/providers/registry/types.ts +19 -1
  98. package/src/providers/resolved-model-policy.ts +4 -4
  99. package/src/providers/routed-config.ts +15 -0
  100. package/src/providers/stale-model-roster-migration.ts +83 -15
  101. package/src/providers/workbuddy-credit-balance.ts +81 -0
  102. package/src/providers/workbuddy-models.ts +37 -0
  103. package/src/providers/workbuddy.ts +60 -0
  104. package/src/responses/reasoning-replay-cache.ts +18 -14
  105. package/src/router.ts +2 -0
  106. package/src/routing/analytics.ts +2 -3
  107. package/src/routing/fallback-policy.ts +3 -3
  108. package/src/server/auth-cors.ts +1 -0
  109. package/src/server/chat-completions.ts +14 -1
  110. package/src/server/chat-native-reasoning.ts +10 -3
  111. package/src/server/chat-native.ts +13 -2
  112. package/src/server/claude-messages.ts +3 -0
  113. package/src/server/index/link-relay-sessions.ts +9 -5
  114. package/src/server/index/optional-listeners.ts +4 -0
  115. package/src/server/local-management-read-client.ts +3 -1
  116. package/src/server/management/combo-routes.ts +10 -4
  117. package/src/server/management/link-routes.ts +5 -2
  118. package/src/server/management/provider-routes.ts +1 -1
  119. package/src/server/port-reclaim.ts +51 -9
  120. package/src/server/request-log-conversation.ts +49 -0
  121. package/src/server/request-log.ts +2 -0
  122. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  123. package/src/server/responses/compact.ts +10 -2
  124. package/src/server/responses/core-combo.ts +53 -31
  125. package/src/server/responses/core-lifetime.ts +2 -2
  126. package/src/server/responses/core-normalize.ts +6 -3
  127. package/src/server/responses/core-opaque-recovery.ts +1 -0
  128. package/src/server/responses/core-options.ts +5 -0
  129. package/src/server/responses/core-replay.ts +2 -1
  130. package/src/server/responses/jev-combo-choice.ts +39 -0
  131. package/src/server/responses/jev-session-pin.ts +203 -0
  132. package/src/server/responses/passthrough-delivery.ts +4 -0
  133. package/src/server/responses/request-prepare.ts +10 -5
  134. package/src/server/responses/request-transport.ts +25 -6
  135. package/src/server/responses/run-turn-execution.ts +2 -4
  136. package/src/server/responses/sidecar-execution.ts +2 -4
  137. package/src/server/responses-phase-inference.ts +267 -0
  138. package/src/server/sse-payload-rewrite.ts +14 -1
  139. package/src/service/definition.ts +20 -8
  140. package/src/types/config.ts +6 -0
  141. package/src/types/provider.ts +9 -1
  142. package/src/types/request.ts +1 -0
  143. package/src/types/wire.ts +1 -0
  144. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  145. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  146. package/src/usage/jev-stats.ts +54 -14
  147. package/gui/dist/assets/index-DGXS51sp.js +0 -94
  148. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
@@ -9,18 +9,35 @@
9
9
  * Without a retry policy it needs the user. With one (the Home's `-R` supervisor and the Child's
10
10
  * own `-L` tunnel both run with one) a reason that has a delay is tried again at `retryAt`, and
11
11
  * the retry attempt runs with `inFlight` while the state still reads failed; a reason without a
12
- * delay stays terminal.
12
+ * delay stays terminal. Each retry that fails again doubles the next delay, up to the policy's
13
+ * `maxRetryDelayMs`; `retries` counts those failed retries (absent before the first one). A due
14
+ * retry whose ssh could not even be started counts too (`failedSpawnTunnel`).
13
15
  */
14
16
 
15
17
  export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
16
- export type StderrClass = "auth" | "hostkey" | "forward" | "network" | "unknown";
18
+ /**
19
+ * `remote_forward` is the Home's `-R` request refused by the peer's sshd: the port is still held,
20
+ * usually by an earlier session the peer has not dropped yet. It fails the link as `forward` and
21
+ * keeps the finer cause in `detail`.
22
+ */
23
+ export type StderrClass = "auth" | "hostkey" | "forward" | "remote_forward" | "network" | "unknown";
24
+ export type TunnelFailureDetail = "remote_forward_conflict";
17
25
 
18
26
  export type TunnelState =
19
27
  | { kind: "idle" }
20
28
  | { kind: "connecting"; since: number }
21
29
  | { kind: "connected"; since: number }
22
30
  | { kind: "reconnecting"; since: number; attempt: number; retryAt: number; inFlight: boolean }
23
- | { kind: "failed"; since: number; reason: TunnelFailure; retryAt?: number; inFlight?: boolean };
31
+ | {
32
+ kind: "failed";
33
+ since: number;
34
+ reason: TunnelFailure;
35
+ retryAt?: number;
36
+ inFlight?: boolean;
37
+ /** Failed retries since the link failed; absent until the first retry fails. */
38
+ retries?: number;
39
+ detail?: TunnelFailureDetail;
40
+ };
24
41
 
25
42
  export type TunnelEvent =
26
43
  | { type: "spawn"; now: number }
@@ -29,9 +46,13 @@ export type TunnelEvent =
29
46
  | { type: "tick"; now: number }
30
47
  | { type: "stop" };
31
48
 
32
- /** Opt-in retry of a failed tunnel: the delay before each failure reason is tried again. */
49
+ /**
50
+ * Opt-in retry of a failed tunnel: the first delay before each failure reason is tried again, and
51
+ * the cap the doubling delay of later retries stops at.
52
+ */
33
53
  export interface TunnelRetryPolicy {
34
54
  retryFailedAfterMs: Partial<Record<TunnelFailure, number>>;
55
+ maxRetryDelayMs?: number;
35
56
  }
36
57
 
37
58
  export const FAILED_AFTER_MS = 5 * 60_000;
@@ -39,19 +60,20 @@ export const BASE_DELAY_MS = 1_000;
39
60
  export const MAX_DELAY_MS = 30_000;
40
61
 
41
62
  /**
42
- * The client-owned tunnel's policy. Timeout and forward failures retry about once a minute. Auth
43
- * retries every five minutes, and because each auth failure schedules the next attempt five
44
- * minutes out, no more than 12 attempts reach the Home's sshd in an hour. A changed host key is a
45
- * security signal and is never retried.
63
+ * The client-owned tunnel's policy. Timeout and forward failures are first retried after a minute,
64
+ * and each retry that fails again doubles the delay up to five minutes (60, 120, 240, then every
65
+ * 300 seconds). Auth starts at the five-minute cap, so no more than 12 attempts reach the Home's
66
+ * sshd in an hour. A changed host key is a security signal and is never retried.
46
67
  */
47
68
  export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
48
69
  retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
70
+ maxRetryDelayMs: 5 * 60_000,
49
71
  };
50
72
 
51
73
  /**
52
74
  * The Home's `-R` supervisor dials the Child's sshd on the same cadence and limits. A sleep, an
53
- * outage longer than FAILED_AFTER_MS, or a forward the Child's sshd still holds for the session
54
- * that just died heals about a minute after it clears, instead of waiting for a proxy restart.
75
+ * outage longer than FAILED_AFTER_MS, or a forward the Child's sshd still holds for a session that
76
+ * died heals at most five minutes after it clears, instead of waiting for a proxy restart.
55
77
  */
56
78
  export const HOME_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = CLIENT_TUNNEL_RETRY_POLICY;
57
79
 
@@ -65,23 +87,59 @@ export function nextDelayMs(attempt: number, random: () => number = Math.random)
65
87
  return Math.round(Math.min(MAX_DELAY_MS, base * jitter));
66
88
  }
67
89
 
90
+ /** The delay before the next retry of `reason` after `retries` failed retries, or undefined. */
91
+ export function failedRetryDelayMs(reason: TunnelFailure, retries: number, policy?: TunnelRetryPolicy): number | undefined {
92
+ const base = policy?.retryFailedAfterMs[reason];
93
+ if (base === undefined) return undefined;
94
+ const cap = Math.max(base, policy?.maxRetryDelayMs ?? base);
95
+ return Math.min(cap, base * 2 ** Math.max(0, Math.min(retries, 16)));
96
+ }
97
+
68
98
  /** A failed state; under a policy that retries `reason` it carries the next attempt's time. */
69
99
  export function failedTunnel(
70
100
  reason: TunnelFailure,
71
101
  now: number,
72
102
  policy?: TunnelRetryPolicy,
73
103
  since: number = now,
104
+ options: { retries?: number; detail?: TunnelFailureDetail } = {},
74
105
  ): TunnelState {
75
- const delay = policy?.retryFailedAfterMs[reason];
106
+ const retries = options.retries ?? 0;
107
+ const delay = failedRetryDelayMs(reason, retries, policy);
108
+ const extra = {
109
+ ...(retries > 0 ? { retries } : {}),
110
+ ...(options.detail ? { detail: options.detail } : {}),
111
+ };
76
112
  return delay === undefined
77
- ? { kind: "failed", since, reason }
78
- : { kind: "failed", since, reason, retryAt: now + delay, inFlight: false };
113
+ ? { kind: "failed", since, reason, ...extra }
114
+ : { kind: "failed", since, reason, retryAt: now + delay, inFlight: false, ...extra };
115
+ }
116
+
117
+ /**
118
+ * The state after an attempt whose ssh could not be started (ssh missing, a pidfile that cannot be
119
+ * written, no listener port). On a due retry of a failed link it counts as one more failed retry
120
+ * from the original moment and keeps the reason it was retrying: an attempt that reached no peer
121
+ * says nothing new about the link, so the backoff keeps its step and an auth failure keeps its
122
+ * five-minute cadence. Any other state fails fresh with `reason`.
123
+ */
124
+ export function failedSpawnTunnel(
125
+ prior: TunnelState | undefined,
126
+ reason: TunnelFailure,
127
+ now: number,
128
+ policy?: TunnelRetryPolicy,
129
+ ): TunnelState {
130
+ if (prior?.kind !== "failed") return failedTunnel(reason, now, policy);
131
+ return failedTunnel(prior.reason, now, policy, prior.since, { retries: (prior.retries ?? 0) + 1, detail: prior.detail });
79
132
  }
80
133
 
81
134
  function failureOf(cls: StderrClass): TunnelFailure | null {
135
+ if (cls === "remote_forward") return "forward";
82
136
  return cls === "auth" || cls === "hostkey" || cls === "forward" ? cls : null;
83
137
  }
84
138
 
139
+ function detailOf(cls: StderrClass): TunnelFailureDetail | undefined {
140
+ return cls === "remote_forward" ? "remote_forward_conflict" : undefined;
141
+ }
142
+
85
143
  export function reduceTunnel(
86
144
  state: TunnelState,
87
145
  event: TunnelEvent,
@@ -104,11 +162,15 @@ export function reduceTunnel(
104
162
  if (state.kind === "failed") {
105
163
  if (!state.inFlight) return state;
106
164
  // A retry of a failed link failed again. It stays failed from the original moment; a
107
- // transient exit keeps the slow cadence (timeout) instead of restarting fast backoff.
108
- return failedTunnel(failureOf(event.stderrClass) ?? "timeout", event.now, policy, state.since);
165
+ // transient exit keeps the slow cadence (timeout) instead of restarting fast backoff, and
166
+ // the next delay doubles up to the policy's cap.
167
+ return failedTunnel(failureOf(event.stderrClass) ?? "timeout", event.now, policy, state.since, {
168
+ retries: (state.retries ?? 0) + 1,
169
+ detail: detailOf(event.stderrClass),
170
+ });
109
171
  }
110
172
  const failure = failureOf(event.stderrClass);
111
- if (failure) return failedTunnel(failure, event.now, policy);
173
+ if (failure) return failedTunnel(failure, event.now, policy, event.now, { detail: detailOf(event.stderrClass) });
112
174
  const since = state.kind === "reconnecting" || state.kind === "connecting" ? state.since : event.now;
113
175
  if (event.now - since >= FAILED_AFTER_MS) return failedTunnel("timeout", event.now, policy);
114
176
  const attempt = state.kind === "reconnecting" ? state.attempt + 1 : 1;
@@ -137,7 +199,8 @@ export function classifySshStderr(stderr: string): StderrClass {
137
199
  || text.includes("no ecdsa host key is known") || text.includes("no ed25519 host key is known")
138
200
  || text.includes("no rsa host key is known") || text.includes("host key is known for")) return "hostkey";
139
201
  if (text.includes("permission denied") || text.includes("too many authentication failures")) return "auth";
140
- if (text.includes("remote port forwarding failed") || text.includes("port forwarding failed")
202
+ if (text.includes("remote port forwarding failed")) return "remote_forward";
203
+ if (text.includes("port forwarding failed")
141
204
  || text.includes("address already in use") || text.includes("cannot listen to port")
142
205
  || text.includes("could not request local forwarding")) return "forward";
143
206
  if (text.includes("connection refused") || text.includes("timed out") || text.includes("could not resolve")
@@ -1,9 +1,24 @@
1
1
  import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync,
2
2
  realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { dirname, isAbsolute, join } from "node:path";
4
5
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
6
  import { remoteError, REMOTE_LIMITS } from "./remote-contract";
6
7
 
8
+ /** macOS reports access ACLs separately from otherwise-private POSIX mode bits. */
9
+ export function macAclOutputIsPrivate(output: string): boolean {
10
+ const lines = output.trimEnd().split("\n");
11
+ return lines.length === 1 && /^[d-][rwxStTs-]{9}[.@]?\s/.test(lines[0]!);
12
+ }
13
+ function checkMacAcl(path: string): void {
14
+ if (process.platform !== "darwin") return;
15
+ const result = spawnSync("/bin/ls", ["-lde", path], {
16
+ encoding: "utf8", timeout: 1_000, shell: false, windowsHide: true,
17
+ env: { ...process.env, LC_ALL: "C" },
18
+ });
19
+ if (result.error || result.status !== 0 || result.stderr || !macAclOutputIsPrivate(result.stdout)) throw new Error();
20
+ }
21
+
7
22
  /** Check the entire path ancestry before using the private messaging subtree. */
8
23
  export function checkRemoteDirectory(path: string): void {
9
24
  checkDirectory(path, true);
@@ -17,6 +32,7 @@ function checkDirectory(path: string, privateLeaf: boolean): void {
17
32
  const final = lstatSync(path);
18
33
  if (!final.isDirectory() || final.isSymbolicLink()
19
34
  || (privateLeaf && (final.uid !== process.getuid!() || (final.mode & 0o077)))) throw new Error();
35
+ if (privateLeaf) checkMacAcl(path);
20
36
  // Root-controlled OS aliases (/tmp and /var on macOS) are not user-controlled redirects.
21
37
  for (let entry = path; ; entry = dirname(entry)) {
22
38
  const stat = lstatSync(entry);
@@ -81,6 +97,7 @@ export function readRemoteFile(path: string): string | null {
81
97
  const stat = fstatSync(fd);
82
98
  if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid!() || (stat.mode & 0o077)
83
99
  || stat.size > REMOTE_LIMITS.storeBytes) throw new Error();
100
+ checkMacAcl(path);
84
101
  const bytes = Buffer.alloc(REMOTE_LIMITS.storeBytes + 1);
85
102
  let size = 0;
86
103
  while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
@@ -98,8 +115,14 @@ export function writeRemoteFile(path: string, text: string, sync = fsyncSync): v
98
115
  const temporary = join(dirname(path), `.write-${crypto.randomUUID()}`);
99
116
  const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
100
117
  try {
101
- try { writeFileSync(fd, text, "utf8"); sync(fd); } finally { closeSync(fd); }
118
+ try {
119
+ try { checkMacAcl(temporary); }
120
+ catch { throw remoteError("unsafe_remote_storage", "Cannot safely publish messaging state: temporary file ACL privacy could not be confirmed."); }
121
+ writeFileSync(fd, text, "utf8"); sync(fd);
122
+ } finally { closeSync(fd); }
102
123
  renameSync(temporary, path);
124
+ try { checkMacAcl(path); }
125
+ catch { throw remoteError("remote_storage_unknown", "Messaging state was replaced, but its ACL privacy and crash durability could not be confirmed; inspect state and permissions before explicit recovery."); }
103
126
  syncRemoteDirectory(dirname(path), sync);
104
127
  } finally { if (remotePathExists(temporary)) unlinkSync(temporary); }
105
128
  }
@@ -260,9 +260,12 @@ function accountListsModel(providerName: string, accountId: string, model: strin
260
260
  /**
261
261
  * Model evidence only ranks accounts already eligible and below any configured cap.
262
262
  *
263
- * Kiro and Antigravity both gate models per account. For Antigravity (Claude 4.6 vs 5.5, Argon)
264
- * an account whose discovered roster lists the model wins; an account whose roster omits it is
265
- * skipped while any other remains; an account with no roster yet is unknown and stays usable.
263
+ * Kiro and Antigravity both gate models per account, but only Antigravity's roster is a gate
264
+ * (#7500). An account whose discovered roster lists the model wins; an account with no fresh
265
+ * roster is unknown and stays usable after it; an account whose fresh roster omits the model is
266
+ * never returned, so a field of only such accounts answers EMPTY rather than falling back to them.
267
+ * Callers treat empty as "no account to move to", and the transport refuses the request through
268
+ * {@link routeAntigravityModel}. Kiro listings remain a preference only.
266
269
  */
267
270
  export function preferKiroModelSupport(
268
271
  providerName: string, ids: string[], model?: string | null, config?: OcxConfig,
@@ -271,8 +274,7 @@ export function preferKiroModelSupport(
271
274
  if (providerName === "google-antigravity") {
272
275
  const positive = ids.filter(id => accountListsModel(providerName, id, model) === true);
273
276
  if (positive.length) return positive;
274
- const notListedAgainst = ids.filter(id => accountListsModel(providerName, id, model) !== false);
275
- return notListedAgainst.length ? notListedAgainst : ids;
277
+ return ids.filter(id => accountListsModel(providerName, id, model) !== false);
276
278
  }
277
279
  if (providerName !== "kiro") return ids;
278
280
  const cap = config?.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
@@ -282,6 +284,83 @@ export function preferKiroModelSupport(
282
284
  return positive.length ? positive : ids;
283
285
  }
284
286
 
287
+ /** Where an Antigravity request may be sent, judged from per-account model discovery (#7500). */
288
+ export type AntigravityModelRoute =
289
+ | { readonly kind: "keep" }
290
+ | { readonly kind: "reroute"; readonly accountId: string }
291
+ | { readonly kind: "refuse"; readonly message: string };
292
+
293
+ const KEEP_ANTIGRAVITY_ROUTE: AntigravityModelRoute = { kind: "keep" };
294
+
295
+ /** Client-facing refusal when no usable account's discovery lists the model. Mirrored in docs-site. */
296
+ export function antigravityModelUnlistedMessage(model: string): string {
297
+ return `No usable Google Antigravity account lists the model "${model}" in its model discovery. `
298
+ + "Choose a model your accounts list, or add an account that has access to it.";
299
+ }
300
+
301
+ /** Client-facing refusal when the chosen account omits the model but a sibling may serve it. */
302
+ export function antigravitySelectedAccountUnlistedMessage(model: string): string {
303
+ return `The selected Google Antigravity account does not list the model "${model}" in its model discovery. `
304
+ + "Select an account that lists it, or enable oauthAccountFailover so OpenCodex can choose one.";
305
+ }
306
+
307
+ /**
308
+ * Fail-closed model routing for Antigravity (#7500).
309
+ *
310
+ * `plannedAccountId` is the account the request would otherwise be sent with. It is kept unless
311
+ * its own fresh roster omits the model: absence of a roster is unknown, never a denial, because
312
+ * rosters live in memory, are refreshed only for the account active during discovery, and expire
313
+ * after six hours, so a restart must not take every account out of service.
314
+ *
315
+ * A planned account with a proven omission is replaced by a usable sibling (not paused, not
316
+ * flagged for reauth) that lists the model, then by one whose roster is unknown; siblings out
317
+ * of cooldown win over cooled ones within each tier. Moving a request before upstream refused it
318
+ * is a proactive choice, so a replacement needs {@link isProactivePreferenceEnabled}, exactly as
319
+ * Kiro's known-refusal exclusion does. Otherwise, or when no sibling qualifies, the request is
320
+ * refused locally instead of being sent to an account known not to serve the model.
321
+ */
322
+ export function routeAntigravityModel(
323
+ config: OcxConfig,
324
+ providerName: string,
325
+ plannedAccountId: string | null | undefined,
326
+ model: string | null | undefined,
327
+ now = Date.now(),
328
+ ): AntigravityModelRoute {
329
+ if (providerName !== "google-antigravity" || !model || !plannedAccountId
330
+ || accountListsModel(providerName, plannedAccountId, model) !== false) return KEEP_ANTIGRAVITY_ROUTE;
331
+ const serving = (getAccountSet(providerName)?.accounts ?? [])
332
+ .filter(account => account.id !== plannedAccountId && account.paused !== true && account.needsReauth !== true)
333
+ .map(account => account.id)
334
+ .filter(id => accountListsModel(providerName, id, model) !== false);
335
+ if (serving.length === 0) return { kind: "refuse", message: antigravityModelUnlistedMessage(model) };
336
+ if (!isProactivePreferenceEnabled(config, providerName, now)) {
337
+ return { kind: "refuse", message: antigravitySelectedAccountUnlistedMessage(model) };
338
+ }
339
+ const ready = new Set(eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, model)));
340
+ const rank = (id: string) => (ready.has(id) ? 0 : 2) + (accountListsModel(providerName, id, model) === true ? 0 : 1);
341
+ const best = serving.reduce((left, right) => rank(right) < rank(left) ? right : left);
342
+ return { kind: "reroute", accountId: best };
343
+ }
344
+
345
+ /**
346
+ * Client-facing refusal when failover is on and a sibling may serve the model, but the request
347
+ * still resolved to an omitting account (the sibling could not be resolved or admitted).
348
+ */
349
+ export function antigravityReplacementUnavailableMessage(model: string): string {
350
+ return `The selected Google Antigravity account does not list the model "${model}" in its model discovery, `
351
+ + "and no account that may serve it could be used for this request. Retry the request.";
352
+ }
353
+
354
+ /** The refusal for an account that already resolved, or null when it may serve the model. */
355
+ export function antigravityModelRefusal(
356
+ config: OcxConfig, providerName: string, accountId: string, model: string | null | undefined, now = Date.now(),
357
+ ): string | null {
358
+ const verdict = routeAntigravityModel(config, providerName, accountId, model, now);
359
+ if (verdict.kind === "keep") return null;
360
+ // A reroute verdict exists only with failover on, so advising to enable it would be wrong.
361
+ return verdict.kind === "refuse" ? verdict.message : antigravityReplacementUnavailableMessage(model!);
362
+ }
363
+
285
364
  function kiroModelListingHasRoom(config: OcxConfig, accountId: string, model: string): boolean {
286
365
  if (kiroAccountSupportsModel(accountId, model) !== true) return false;
287
366
  const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
@@ -320,7 +399,11 @@ export function hasEligibleGenericOAuthFailoverTarget(
320
399
  const set = getAccountSet(providerName);
321
400
  if (!set || set.accounts.length < 2) return false;
322
401
  const family = classifyModelFamilyForQuota(providerName, requestedModelId);
323
- return eligibleIdsIn(set, providerName, now, family).some(id => id !== failedAccountId);
402
+ // Same model filter rotation applies, so an Antigravity sibling whose roster omits the
403
+ // model is not advertised as a target rotation would then refuse to pick.
404
+ const siblings = eligibleIdsIn(set, providerName, now, family).filter(id => id !== failedAccountId);
405
+ return (providerName === "google-antigravity"
406
+ ? preferKiroModelSupport(providerName, siblings, requestedModelId) : siblings).length > 0;
324
407
  }
325
408
 
326
409
  /** Select a live Antigravity sibling using activation captured before the refused send. */
@@ -344,7 +427,8 @@ export function rotateAntigravityAccountOnAuthRefusal(
344
427
  });
345
428
  sweepExpiredOnWrite(now);
346
429
  }
347
- const eligible = new Set(eligibleIdsIn(set, "google-antigravity", now, family));
430
+ const eligible = new Set(preferKiroModelSupport("google-antigravity",
431
+ eligibleIdsIn(set, "google-antigravity", now, family).filter(id => id !== failedAccountId), requestedModelId));
348
432
  const order = set.accounts.map(row => row.id);
349
433
  const start = order.indexOf(failedAccountId);
350
434
  const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
@@ -50,6 +50,8 @@ import { validateDevinApiBaseUrl } from "./devin/api-base";
50
50
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
51
51
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
52
52
  import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
53
+ import { loginWorkBuddy, refreshWorkBuddyToken } from "./workbuddy";
54
+ import { WORKBUDDY_CONFIG_URL, workBuddyHeaders } from "../providers/workbuddy";
53
55
  import { DSH_ACCOUNT_AUTH } from "./dsh";
54
56
  import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } from "./orcarouter";
55
57
  import { ANTIGRAVITY_DISCOVERY_UA } from "../adapters/google-antigravity-wire";
@@ -98,6 +100,7 @@ import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
98
100
  import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
99
101
  import { AnthropicCrossInstanceDuplicateError, AnthropicLocalCliImportError, assertAnthropicCredentialSource } from "./store-anthropic-instance";
100
102
  export interface OAuthAccessSnapshot {
103
+ workbuddy?: import("./types").WorkBuddyOAuthMetadata;
101
104
  provider: string;
102
105
  accountId: string;
103
106
  generation: string;
@@ -246,6 +249,11 @@ function oauthDefaultModel(id: string): string {
246
249
  }
247
250
 
248
251
  export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
252
+ "workbuddy-ai": {
253
+ login: loginWorkBuddy, refresh: refreshWorkBuddyToken,
254
+ providerConfig: oauthConfig("workbuddy-ai"), defaultModel: oauthDefaultModel("workbuddy-ai"),
255
+ defaultRefreshPolicy: "lazy-only",
256
+ },
249
257
  "dsh-account": {
250
258
  ...DSH_ACCOUNT_AUTH,
251
259
  providerConfig: oauthConfig("dsh-account"),
@@ -561,6 +569,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
561
569
  accountId,
562
570
  generation: credentialGeneration(cred),
563
571
  accessToken: cred.access,
572
+ ...(oauthProvider === "workbuddy-ai" && cred.workbuddy ? { workbuddy: { ...cred.workbuddy } } : {}),
564
573
  ...(cred.projectId ? { projectId: cred.projectId } : {}),
565
574
  ...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}),
566
575
  ...(oauthProvider === "zed" && cred.accountId ? { providerUserId: cred.accountId } : {}),
@@ -717,6 +726,7 @@ export async function getValidAccessTokenSnapshot(
717
726
 
718
727
  /** Providers whose upstream-401 replay path may force a snapshot refresh. */
719
728
  const FORCE_REFRESH_PROVIDERS = new Set([
729
+ "workbuddy-ai",
720
730
  "xai",
721
731
  "github-copilot",
722
732
  "kiro",
@@ -1177,6 +1187,7 @@ function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConf
1177
1187
  * `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` response.
1178
1188
  */
1179
1189
  export interface ModelsRequestObservedAuth {
1190
+ readonly workbuddy?: import("./types").WorkBuddyOAuthMetadata;
1180
1191
  readonly oauthApiBaseUrl?: string;
1181
1192
  }
1182
1193
 
@@ -1186,6 +1197,10 @@ export function buildModelsRequest(
1186
1197
  providerName = "",
1187
1198
  observedAuth?: ModelsRequestObservedAuth,
1188
1199
  ): { method?: "POST"; url: string; headers: Record<string, string> } {
1200
+ if (providerName === "workbuddy-ai" || prov.adapter === "workbuddy-ai") {
1201
+ const allowed = mayResolveModelsOAuth(providerName, prov);
1202
+ return { url: WORKBUDDY_CONFIG_URL, headers: workBuddyHeaders(allowed && observedAuth?.workbuddy ? apiKey : undefined, observedAuth?.workbuddy) };
1203
+ }
1189
1204
  const authorizedTarget = captureModelsOAuthTarget(providerName, prov);
1190
1205
  if (prov.authMode === "oauth" && !mayResolveModelsOAuth(providerName, prov)) apiKey = undefined;
1191
1206
  const transportSeed = modelDiscoveryTransportSeed(providerName, prov);
@@ -1,3 +1,4 @@
1
+ import { assertWorkBuddyDestination } from "../providers/workbuddy";
1
2
  import type { OcxProviderConfig } from "../types";
2
3
  import { isBuiltinAnthropicInstanceRow } from "../providers/anthropic-instance";
3
4
  import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
@@ -8,6 +9,10 @@ export function mayResolveModelsOAuth(name: string, provider: OcxProviderConfig
8
9
  // The DSH account token is pinned to its own adapter and fixed DeepSeek hosts. Its catalog is
9
10
  // static, so no discovery path may resolve it, whatever adapter or baseUrl the row was edited to.
10
11
  if (name === "dsh-account") return false;
12
+ if (name === "workbuddy-ai" || provider?.adapter === "workbuddy-ai") {
13
+ if (name !== "workbuddy-ai" || provider?.adapter !== "workbuddy-ai" || provider.disabled) return false;
14
+ try { assertWorkBuddyDestination(provider.baseUrl); return true; } catch { return false; }
15
+ }
11
16
  return name !== "anthropic2" || (provider !== undefined && provider.disabled !== true
12
17
  && isBuiltinAnthropicInstanceRow(name, provider));
13
18
  }
@@ -23,6 +23,7 @@ import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink";
23
23
  import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
24
24
  import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
25
25
  import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
26
+ import { normalizeWorkBuddyMetadata } from "../providers/workbuddy";
26
27
  import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity";
27
28
  import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id";
28
29
  import { assertNoCrossAnthropicRegistration, assertAnthropicCredentialSource, assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance";
@@ -354,7 +355,7 @@ export function clearOAuthRefreshIntent(provider: string, accountId: string, gen
354
355
  });
355
356
  }
356
357
  export function credentialGeneration(cred: OAuthCredentials): string {
357
- return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
358
+ return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires, ...(cred.workbuddy ? [cred.workbuddy] : [])])).digest("hex");
358
359
  }
359
360
 
360
361
  /** Load and normalize the OAuth auth store with hardened permissions, and hand the result to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
@@ -548,6 +549,8 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
548
549
  };
549
550
  if (typeof candidate.email === "string" && candidate.email.length > 0) normalized.email = candidate.email;
550
551
  if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
552
+ const workbuddy = normalizeWorkBuddyMetadata(candidate.workbuddy);
553
+ if (workbuddy) normalized.workbuddy = workbuddy;
551
554
  const identity = normalizeAnthropicIdentity(candidate.anthropicIdentity, candidate.access);
552
555
  if (identity) normalized.anthropicIdentity = identity;
553
556
  if (candidate.plan !== undefined) normalized.plan = normalizeOAuthPlan(candidate.plan) ?? null;
@@ -215,6 +215,13 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
215
215
  if (!record || record.deletedAt != null) continue;
216
216
  const cred = record.credential;
217
217
  if (!cred) continue;
218
+ // A grant the token endpoint already called revoked or expired cannot be refreshed; posting
219
+ // it again only buys another 401 per backoff window (#7592). The OAuth branch above skips
220
+ // needsReauth for the same reason. A re-login or a completed validation rewrites the record
221
+ // without the marker, which is what readmits the account. A source-linked credential spends
222
+ // no grant of its own, so it keeps the sweep, matching the quota probe's hold.
223
+ if (record.lastCodexValidationTerminal === true && record.lastCodexValidationStatus === "failed"
224
+ && !cred.sourceAuthPath) continue;
218
225
  const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
219
226
  const needsWarmup = opts.codexWarmupEnabled
220
227
  && !record.codexValidationPending
@@ -54,7 +54,16 @@ export interface MuseOAuthMetadata {
54
54
  tierName?: string;
55
55
  }
56
56
 
57
+ export interface WorkBuddyOAuthMetadata {
58
+ uid: string;
59
+ domain: string;
60
+ enterpriseId?: string;
61
+ refreshExpiresAt?: number;
62
+ }
63
+
57
64
  export type OAuthCredentials = {
65
+ /** Account-scoped WorkBuddy identity, kept inside the protected credential store. */
66
+ workbuddy?: WorkBuddyOAuthMetadata;
58
67
  refresh: string;
59
68
  access: string;
60
69
  /** Epoch ms after any small provider-specific early-refresh margin; the shared gate adds 1 minute. */
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { readBoundedResponseBody } from "../lib/bounded-body";
4
+ import { WORKBUDDY_ORIGIN, WORKBUDDY_USER_AGENT, workBuddyHeaderValue, workBuddyHeaders } from "../providers/workbuddy";
5
+ import type { OAuthController, OAuthCredentials, WorkBuddyOAuthMetadata } from "./types";
6
+
7
+ type Json = Record<string, unknown>;
8
+ function record(value: unknown): Json {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Json : {};
10
+ }
11
+
12
+ export class WorkBuddyApiError extends Error {
13
+ constructor(readonly status: number, readonly code: number) {
14
+ super(`WorkBuddy AI request failed (HTTP ${status}, code ${code})`);
15
+ }
16
+ }
17
+
18
+ /** Fixed-origin, bounded reads; upstream error/profile text never enters diagnostics. */
19
+ export async function workBuddyApi(
20
+ path: string, headers: Record<string, string>, signal?: AbortSignal, post = false, payload: Json = {},
21
+ ): Promise<Json> {
22
+ const timeout = AbortSignal.timeout(15_000);
23
+ const boundedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
24
+ const response = await fetch(`${WORKBUDDY_ORIGIN}${path}`, {
25
+ method: post ? "POST" : "GET", redirect: "error", signal: boundedSignal,
26
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": WORKBUDDY_USER_AGENT, ...headers },
27
+ ...(post ? { body: JSON.stringify(payload) } : {}),
28
+ }).catch(() => {
29
+ signal?.throwIfAborted();
30
+ throw new Error("WorkBuddy AI request unavailable");
31
+ });
32
+ const body = await readBoundedResponseBody(response, {
33
+ signal: boundedSignal, maxBytes: 256 * 1024, totalTimeoutMs: 15_000, inactivityTimeoutMs: 15_000, fatalUtf8: true,
34
+ }).catch(() => {
35
+ signal?.throwIfAborted();
36
+ throw new Error("WorkBuddy AI response unavailable");
37
+ });
38
+ let env: Json;
39
+ try {
40
+ if (body.oversized || body.truncated || !body.displaySafe) throw new Error();
41
+ env = record(JSON.parse(body.text));
42
+ } catch {
43
+ signal?.throwIfAborted();
44
+ if (!response.ok) throw new WorkBuddyApiError(response.status, -1);
45
+ throw new Error("WorkBuddy AI returned an invalid response");
46
+ }
47
+ const code = typeof env.code === "number" || typeof env.code === "string" && /^\d+$/.test(env.code)
48
+ ? Number(env.code) : NaN;
49
+ if (!response.ok || !Number.isFinite(code) || code !== 0) {
50
+ throw new WorkBuddyApiError(response.status, Number.isSafeInteger(code) ? code : -1);
51
+ }
52
+ return record(env.data);
53
+ }
54
+
55
+ function expiry(got: Json, absolute: string, relative: string, fallback?: number): number | undefined {
56
+ const at = got[absolute], seconds = got[relative];
57
+ if (typeof at === "number" && Number.isSafeInteger(at) && at > 0) return at;
58
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0) {
59
+ const result = Date.now() + seconds * 1000;
60
+ if (Number.isSafeInteger(result)) return result;
61
+ }
62
+ return fallback;
63
+ }
64
+
65
+ function credentials(got: Json, metadata: WorkBuddyOAuthMetadata, previous?: OAuthCredentials): OAuthCredentials {
66
+ const access = got.accessToken;
67
+ const refresh = got.refreshToken ?? previous?.refresh;
68
+ const expires = expiry(got, "expiresAt", "expiresIn");
69
+ const domain = got.domain ?? metadata.domain;
70
+ if (!workBuddyHeaderValue(access, 32768) || !workBuddyHeaderValue(refresh, 32768)
71
+ || !expires || expires <= Date.now() || !workBuddyHeaderValue(domain)) throw new Error("WorkBuddy AI returned invalid credentials");
72
+ return {
73
+ access, refresh, expires, source: "oauth",
74
+ // A uid may belong to both a personal plan and a team; they are separate account slots.
75
+ accountId: JSON.stringify([metadata.uid, metadata.enterpriseId ?? ""]),
76
+ workbuddy: { ...metadata, domain,
77
+ refreshExpiresAt: expiry(got, "refreshExpiresAt", "refreshExpiresIn", metadata.refreshExpiresAt) },
78
+ };
79
+ }
80
+
81
+ async function poll(path: string, headers: Record<string, string>, retryCode: number, signal: AbortSignal): Promise<Json> {
82
+ while (true) {
83
+ signal.throwIfAborted();
84
+ try {
85
+ const result = await workBuddyApi(path, headers, signal);
86
+ if (Object.keys(result).length) return result;
87
+ } catch (error) {
88
+ if (!(error instanceof WorkBuddyApiError) || (error.code !== retryCode && ![408, 429].includes(error.status))) throw error;
89
+ }
90
+ await delay(1000, undefined, { signal });
91
+ }
92
+ }
93
+
94
+ export async function loginWorkBuddy(ctrl: OAuthController): Promise<OAuthCredentials> {
95
+ const deadline = AbortSignal.timeout(5 * 60_000);
96
+ const signal = ctrl.signal ? AbortSignal.any([ctrl.signal, deadline]) : deadline;
97
+ signal.throwIfAborted();
98
+ const state = await workBuddyApi("/v2/plugin/auth/state?platform=workbuddy-ai", {
99
+ "X-No-Authorization": "true", "X-No-User-Id": "true",
100
+ "X-No-Enterprise-Id": "true", "X-No-Department-Info": "true",
101
+ }, signal, true);
102
+ if (!workBuddyHeaderValue(state.state, 4096) || typeof state.authUrl !== "string" || state.authUrl.length > 8192) throw new Error("WorkBuddy AI returned an invalid login state");
103
+ let url: URL;
104
+ try { url = new URL(state.authUrl); } catch { throw new Error("WorkBuddy AI returned an invalid login URL"); }
105
+ if (![WORKBUDDY_ORIGIN, "https://www.codebuddy.ai"].includes(url.origin) || url.username || url.password) throw new Error("WorkBuddy AI returned an untrusted login URL");
106
+ url.searchParams.set("version", "2.0.0");
107
+ url.searchParams.set("loginSessionId", randomUUID());
108
+ ctrl.onAuth?.({ url: url.href, instructions: "Sign in to WorkBuddy AI in your browser. OpenCodex will finish automatically." });
109
+ const query = encodeURIComponent(state.state);
110
+ const token = await poll(`/v2/plugin/auth/token?state=${query}`, {}, 11217, signal);
111
+ if (!workBuddyHeaderValue(token.accessToken, 32768)) throw new Error("WorkBuddy AI returned no access token");
112
+ const domain = token.domain ?? "www.workbuddy.ai";
113
+ if (!workBuddyHeaderValue(domain)) throw new Error("WorkBuddy AI returned an invalid account domain");
114
+ const headers = { Authorization: `Bearer ${token.accessToken}`, "X-Domain": domain, "X-No-User-Id": "true", "X-No-Enterprise-Id": "true" };
115
+ const who = await poll(`/v2/plugin/login/account?state=${query}`, headers, 12151, signal);
116
+ if (!workBuddyHeaderValue(who.uid)) throw new Error("WorkBuddy AI account identity unavailable");
117
+ let enterpriseId = who.enterpriseId;
118
+ if (!enterpriseId && typeof who.type === "string" && who.type.toLowerCase() !== "personal") {
119
+ const result = await workBuddyApi("/v2/plugin/accounts", headers, signal);
120
+ const mine = (Array.isArray(result.accounts) ? result.accounts : []).map(record)
121
+ .filter(a => a.uid === who.uid && workBuddyHeaderValue(a.enterpriseId));
122
+ const same = mine.filter(a => a.type === who.type), last = mine.filter(a => a.lastLogin === true);
123
+ enterpriseId = (same.length === 1 ? same[0] : last.length === 1 ? last[0] : mine.length === 1 ? mine[0] : undefined)?.enterpriseId;
124
+ if (!enterpriseId) throw new Error("WorkBuddy AI team identity unavailable; sign in again");
125
+ }
126
+ if (enterpriseId !== undefined && enterpriseId !== "" && !workBuddyHeaderValue(enterpriseId)) throw new Error("WorkBuddy AI returned an invalid team identity");
127
+ return credentials(token, { uid: who.uid, domain, ...(enterpriseId ? { enterpriseId: enterpriseId as string } : {}) });
128
+ }
129
+
130
+ export async function refreshWorkBuddyToken(refresh: string, signal?: AbortSignal, previous?: OAuthCredentials): Promise<OAuthCredentials> {
131
+ const metadata = previous?.workbuddy;
132
+ if (!metadata || !workBuddyHeaderValue(refresh, 32768)) throw new Error("WorkBuddy AI requires a new browser login");
133
+ if (metadata.refreshExpiresAt && metadata.refreshExpiresAt <= Date.now()) throw new Error("WorkBuddy AI refresh token expired; sign in again");
134
+ const headers = workBuddyHeaders(undefined, metadata);
135
+ headers["X-Refresh-Token"] = refresh;
136
+ headers["X-Auth-Refresh-Source"] = "plugin";
137
+ const token = await workBuddyApi("/v2/plugin/auth/token/refresh", headers, signal, true);
138
+ return credentials(token, metadata, previous);
139
+ }