@bitkyc08/opencodex 2.48.0 → 2.50.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -537,6 +537,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
537
537
  // hop-by-hop header alone (oven-sh/bun#20492).
538
538
  return requestFetch(request.url, applyUpstreamRecoveryInit({
539
539
  method: request.method,
540
+ redirect: "manual",
540
541
  headers: h,
541
542
  body: request.body,
542
543
  signal: headerDeadline.signal,
@@ -80,6 +80,7 @@ export async function submitVideoJob(
80
80
 
81
81
  const resp = await fetch(`${auth.baseUrl}/videos/generations`, {
82
82
  method: "POST",
83
+ redirect: "manual",
83
84
  headers: {
84
85
  "Authorization": `Bearer ${auth.token}`,
85
86
  "Content-Type": "application/json",
@@ -118,6 +119,7 @@ export async function pollVideoJob(
118
119
 
119
120
  const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, {
120
121
  method: "GET",
122
+ redirect: "manual",
121
123
  headers: {
122
124
  "Authorization": `Bearer ${auth.token}`,
123
125
  },
@@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
190
190
  id: "hermes",
191
191
  configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home),
192
192
  detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home),
193
+ sourcePreservingYaml: { path: ["providers", "opencodex"] },
193
194
  },
194
195
  openclaw: {
195
196
  id: "openclaw",
package/src/lib/errors.ts CHANGED
@@ -209,6 +209,14 @@ export function classifyError(status: number, type: string, message: string): Oc
209
209
  if (type === "input_admission_refused") {
210
210
  return { message, type: "invalid_request_error", code: "input_admission_refused" };
211
211
  }
212
+ // A LOCAL inbound admission refusal (#3573) keeps its own code for the same reason as the
213
+ // preflight refusal above. #4112 gave the UPSTREAM 413 on this surface
214
+ // `context_length_exceeded`; without a distinct code here a client cannot tell a body the
215
+ // proxy never read from a turn the provider itself rejected, and only one of the two is
216
+ // fixed by raising `maxInboundBodyBytes`.
217
+ if (type === "inbound_body_too_large") {
218
+ return { message, type: "invalid_request_error", code: "inbound_body_too_large" };
219
+ }
212
220
  if (
213
221
  text.includes("context_length_exceeded") ||
214
222
  text.includes("context window") ||
@@ -10,6 +10,31 @@ export function maskEmail(value: string | null | undefined): string | null {
10
10
  return `${local[0]}***${local[local.length - 1]}@${domain}`;
11
11
  }
12
12
 
13
+ /**
14
+ * Whether stored account emails are masked in management and CLI projections (#3859).
15
+ *
16
+ * Fail closed on every ambiguity: a missing config, a missing `privacy` block, and a missing or
17
+ * malformed `maskEmails` all mask. Only the literal boolean `false` unmasks, so a hand-edited
18
+ * `"false"` string or a typo cannot silently disclose an address. Callers read this once at the
19
+ * request boundary and pass the answer down — the projection helpers take a boolean rather than
20
+ * a config so that `getLoginStatus` stays free of config I/O.
21
+ */
22
+ export function emailMaskingEnabled(config: { privacy?: { maskEmails?: boolean } } | null | undefined): boolean {
23
+ return config?.privacy?.maskEmails !== false;
24
+ }
25
+
26
+ /**
27
+ * Project a stored account email for a surface outside the proxy (#3859).
28
+ *
29
+ * One redaction decision for every call site, rather than each one forking on the flag: an
30
+ * unmasked projection still normalises an empty or absent address to `null`, so a consumer's
31
+ * "is there an email" test cannot start answering differently just because masking is off.
32
+ */
33
+ export function projectEmail(value: string | null | undefined, mask: boolean): string | null {
34
+ if (!mask) return value ? value : null;
35
+ return maskEmail(value);
36
+ }
37
+
13
38
  export function maskAccountId(value: string | null | undefined): string | null {
14
39
  if (!value) return null;
15
40
  const id = value.trim();
@@ -66,12 +66,30 @@ export function gracefulStopHost(hostname: string | undefined): string {
66
66
  }
67
67
 
68
68
  /**
69
- * Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered
70
- * that it must NOT be stopped from here, so callers must not escalate to a forced kill.
69
+ * `"refused"` forbids forced stop. `"teardown-unconfirmed"` means the process exited,
70
+ * but its assigned shared teardown was not confirmed; callers must not kill it again.
71
71
  */
72
- export type GracefulStopResult = boolean | "refused";
72
+ export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";
73
73
 
74
- /** A proxy declined shutdown because a service under another home owns it (HTTP 409). */
74
+ /**
75
+ * The server's own explanation for the most recent 409, captured so `stopProxy` can report
76
+ * the real reason. There is more than one: a scheduler wrapper under another home, or the
77
+ * proxy being the installed service itself (#4023). Module-scoped because
78
+ * `GracefulStopResult` is a public contract with several callers, and widening it to carry
79
+ * the text would change every one of them for a message only this file reports.
80
+ */
81
+ let lastRefusalMessage: string | null = null;
82
+
83
+ /** The server's explanation for the most recent 409, or `null` when it sent none. */
84
+ export function lastStopRefusalMessage(): string | null {
85
+ return lastRefusalMessage;
86
+ }
87
+
88
+ /**
89
+ * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a
90
+ * scheduler wrapper under another home, or the proxy being the installed service itself
91
+ * (#4023) — so the server's own message is carried through rather than guessed at.
92
+ */
75
93
  export class ProxyOwnershipRefusedError extends Error {}
76
94
 
77
95
  /**
@@ -82,6 +100,8 @@ export class ProxyOwnershipRefusedError extends Error {}
82
100
  * chance to run its shutdown handlers. Returns false when the proxy can't be reached
83
101
  * or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"`
84
102
  * when the proxy declines the stop (HTTP 409), which callers must NOT force past.
103
+ * True requires the expected shared-teardown response and an observed exit. It does not
104
+ * attest the process exit code or completion of every drain/shutdown hook.
85
105
  */
86
106
  export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
87
107
  const readRuntime = io.readRuntime ?? readRuntimePort;
@@ -92,6 +112,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
92
112
  const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
93
113
  if (token) headers["x-opencodex-api-key"] = token;
94
114
  const fetchFn = io.fetchFn ?? fetch;
115
+ let sharedTeardownConfirmed = false;
95
116
  try {
96
117
  // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
97
118
  // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
@@ -111,8 +132,23 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
111
132
  // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to
112
133
  // SIGTERM here would run the daemon's cleanup and strip shared config out from under the
113
134
  // still-running service. Report the refusal instead of forcing.
114
- if (res.status === 409) return "refused";
135
+ if (res.status === 409) {
136
+ lastRefusalMessage = await res.json()
137
+ .then(body => {
138
+ const message = (body as { message?: unknown } | null)?.message;
139
+ return typeof message === "string" && message.trim() ? message.trim() : null;
140
+ })
141
+ .catch(() => null);
142
+ return "refused";
143
+ }
115
144
  if (!res.ok) return false;
145
+ const body: unknown = await res.json().catch(() => null);
146
+ const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
147
+ sharedTeardownConfirmed = body !== null
148
+ && typeof body === "object"
149
+ && !Array.isArray(body)
150
+ && "success" in body && body.success === true
151
+ && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
116
152
  } catch {
117
153
  return false;
118
154
  }
@@ -120,7 +156,8 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
120
156
  // Honor the server's own drain window: /api/stop answers 200 first, then drains for
121
157
  // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
122
158
  const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
123
- return waitExit(pid, exitTimeoutMs);
159
+ if (!waitExit(pid, exitTimeoutMs)) return false;
160
+ return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
124
161
  }
125
162
 
126
163
  function drainDeadlineMs(): number {
@@ -140,10 +177,17 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b
140
177
  // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
141
178
  // config while that service keeps the proxy alive.
142
179
  throw new ProxyOwnershipRefusedError(
143
- "The running proxy refused to stop: a service installed under a different "
144
- + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
180
+ lastRefusalMessage
181
+ ?? "The running proxy refused to stop: a service installed under a different "
182
+ + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
145
183
  );
146
184
  }
185
+ if (graceful === "teardown-unconfirmed") {
186
+ // Exit was observed, so do not enter the forced-stop fallback. Returning false keeps
187
+ // shared restoration with `ocx stop` instead of claiming that the proxy completed it.
188
+ await waitForStoppedPort(runtime, pid);
189
+ return false;
190
+ }
147
191
  if (graceful) {
148
192
  await waitForStoppedPort(runtime, pid);
149
193
  return true;
@@ -226,6 +226,7 @@ export async function fetchWithAttemptDeadline(
226
226
  return await executor(url, {
227
227
  ...init,
228
228
  headers,
229
+ redirect: "manual",
229
230
  signal: attemptTimeout.signal,
230
231
  });
231
232
  } finally {
@@ -40,6 +40,62 @@ export function extractAccountId(idToken?: string, accessToken?: string): string
40
40
  return undefined;
41
41
  }
42
42
 
43
+ /**
44
+ * Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
45
+ * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
46
+ * https://api.openai.com/auth namespace claim. A generic organizations claim is NOT domain
47
+ * evidence. JWT claims are decoded locally as routing markers, never as authenticity proof.
48
+ *
49
+ * absent — no JWT, a payload that is not a JSON object, or an object carrying neither
50
+ * marker key: the token may be a foreign credential and legacy foreign handling
51
+ * applies. This function is total; it never throws on an attacker-shaped token.
52
+ * invalid — a marker key is present but yields no usable account id (non-string, blank,
53
+ * namespace that is not an object, namespace without the claim) or the two
54
+ * markers disagree. Presence is decided by the KEY, not by its shape, so a token
55
+ * that claims this domain can never fall through to foreign handling just
56
+ * because its marker is malformed.
57
+ * valid — one consistent, non-blank ChatGPT account id.
58
+ */
59
+ export type ChatGptDomainClaim =
60
+ | { kind: "absent" }
61
+ | { kind: "invalid" }
62
+ | { kind: "valid"; accountId: string };
63
+
64
+ const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth";
65
+
66
+ /** A usable account id is a non-blank string; blank or non-string values are malformed. */
67
+ function usableAccountId(value: unknown): string | undefined {
68
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
69
+ }
70
+
71
+ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
72
+ const payload: unknown = decodeJwtPayload(token);
73
+ // decodeJwtPayload returns whatever the payload segment parses to, which may be a
74
+ // primitive or an array. Those carry no marker and must not reach the key lookups,
75
+ // where `in`/hasOwn would throw and take the whole request down.
76
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "absent" };
77
+ const claims = payload as Record<string, unknown>;
78
+ // Presence is the KEY being there, as an own key. A reserved namespace that is null, a
79
+ // primitive, an array, or an object without the claim is a present-but-broken marker, so
80
+ // it stays invalid instead of being treated as a foreign token.
81
+ const topPresent = Object.hasOwn(claims, "chatgpt_account_id");
82
+ const nsPresent = Object.hasOwn(claims, CHATGPT_AUTH_NAMESPACE);
83
+ if (!topPresent && !nsPresent) return { kind: "absent" };
84
+ const topId = topPresent ? usableAccountId(claims.chatgpt_account_id) : undefined;
85
+ if (topPresent && !topId) return { kind: "invalid" };
86
+ let nsId: string | undefined;
87
+ if (nsPresent) {
88
+ const ns = claims[CHATGPT_AUTH_NAMESPACE];
89
+ const nsObj = ns !== null && typeof ns === "object" && !Array.isArray(ns)
90
+ ? ns as Record<string, unknown> : undefined;
91
+ nsId = nsObj ? usableAccountId(nsObj.chatgpt_account_id) : undefined;
92
+ if (!nsId) return { kind: "invalid" };
93
+ }
94
+ if (topId && nsId && topId !== nsId) return { kind: "invalid" };
95
+ const accountId = topId ?? nsId;
96
+ return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
97
+ }
98
+
43
99
  export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
44
100
  for (const token of [idToken, accessToken]) {
45
101
  if (!token) continue;
@@ -50,6 +106,33 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
50
106
  return undefined;
51
107
  }
52
108
 
109
+ /**
110
+ * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
111
+ * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
112
+ * `conflict` is true only when the two chatgpt_account_id encodings are both present and
113
+ * disagree — organizations entries are workspace memberships, not identity, so they never
114
+ * participate. Never logs token material.
115
+ */
116
+ export function extractAccountIdClaims(token?: string): { accountId: string | undefined; conflict: boolean } {
117
+ if (!token) return { accountId: undefined, conflict: false };
118
+ const payload = decodeJwtPayload(token);
119
+ if (!payload) return { accountId: undefined, conflict: false };
120
+ const top = typeof payload.chatgpt_account_id === "string" ? payload.chatgpt_account_id : undefined;
121
+ const ns = payload["https://api.openai.com/auth"];
122
+ const namespaced = ns && typeof ns === "object"
123
+ && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string"
124
+ ? (ns as Record<string, unknown>).chatgpt_account_id as string
125
+ : undefined;
126
+ const orgs = payload.organizations;
127
+ const org = Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string"
128
+ ? orgs[0].id as string
129
+ : undefined;
130
+ return {
131
+ accountId: top ?? namespaced ?? org,
132
+ conflict: top !== undefined && namespaced !== undefined && top !== namespaced,
133
+ };
134
+ }
135
+
53
136
  export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
54
137
  const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
55
138
  // This parses a response from an external boundary, so the access token is
@@ -1,7 +1,7 @@
1
1
  import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
2
2
  import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
3
3
  import { isAccountNeedsReauth } from "../codex/account-runtime-state";
4
- import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
4
+ import { getCodexAccountCredential, listCodexAccountIds, readCodexAccountRecord } from "../codex/account-store";
5
5
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
6
6
  import { readRuntimePort } from "../config/process-state";
7
7
  import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
@@ -15,7 +15,7 @@ export type OAuthAccountHealth =
15
15
  | { status: "healthy" }
16
16
  | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
17
17
  | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
18
- | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" };
18
+ | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" };
19
19
 
20
20
  export type OAuthHealthLabel =
21
21
  | "Healthy"
@@ -24,7 +24,8 @@ export type OAuthHealthLabel =
24
24
  | "Reauthentication required"
25
25
  | "Refresh failed"
26
26
  | "Metadata mismatch"
27
- | "Credential conflict";
27
+ | "Credential conflict"
28
+ | "Validation pending";
28
29
 
29
30
  /** Shared masked-id fallback when `maskAccountId` returns nullish. */
30
31
  export const MASKED_ACCOUNT_FALLBACK = "account-…????";
@@ -88,6 +89,9 @@ export function projectOAuthAccountHealth(input: {
88
89
  export const CODEX_REAUTH_ACTION = "reauthenticate via the dashboard Codex account pool";
89
90
 
90
91
  function actionFor(provider: string, health: OAuthAccountHealth): string | undefined {
92
+ if (health.status === "warning" && health.reason === "validation_pending") {
93
+ return "wait for quota recovery, then click Refresh quotas in the dashboard Codex account pool to finish validation";
94
+ }
91
95
  if (health.status === "reauth_required") {
92
96
  if (provider === "codex") return CODEX_REAUTH_ACTION;
93
97
  return `run \`ocx login ${provider}\``;
@@ -112,6 +116,8 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
112
116
  return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
113
117
  case "warning":
114
118
  switch (health.reason) {
119
+ case "validation_pending":
120
+ return "Validation pending";
115
121
  case "refresh_conflict":
116
122
  return "Credential conflict";
117
123
  case "metadata_mismatch":
@@ -198,11 +204,42 @@ export function projectCodexAccountHealth(input: {
198
204
  needsReauth: boolean;
199
205
  now?: number;
200
206
  }): OAuthAccountHealth {
207
+ // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
208
+ // file, and the main account lives in the native Codex auth file rather than the pool store,
209
+ // so a lookup for it could only ever miss.
210
+ const stored = input.accountId !== MAIN_CODEX_ACCOUNT_ID ? readCodexAccountRecord(input.accountId) : null;
211
+ const record = stored?.deletedAt == null ? stored : null;
212
+
213
+ // A successful quota read is not evidence that model authorization recovered.
214
+ // Preserve this guidance until validation succeeds or reauthentication replaces it.
215
+ const validationAuthFailed = record !== null
216
+ && record.codexValidationPending === true
217
+ && record.lastCodexValidationStatus === "failed"
218
+ && (record.lastCodexValidationError === "http_status:401" || record.lastCodexValidationError === "http_status:403");
219
+
220
+ // A persisted terminal verdict outranks the in-memory reauth flag rather than duplicating it:
221
+ // the flag lives in this process and a revoked grant does not. Without it, an account whose
222
+ // grant was revoked upstream keeps its login-time `lastCodexValidationStatus: "ok"` and every
223
+ // surface reports it healthy until someone tries to use it (#4120). Only a re-login clears the
224
+ // marker, so `reauth_required` is the accurate projection — and it is deliberately checked
225
+ // ahead of any cooldown, because telling an operator to wait out a rate limit on a credential
226
+ // that will never work again is a false promise.
227
+ const terminalGrantFailure = record !== null
228
+ && record.lastCodexValidationTerminal === true
229
+ && record.lastCodexValidationStatus === "failed";
230
+
231
+ const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
232
+
233
+ // Deferred validation is only worth reporting while the credential itself is still viable. A
234
+ // revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
235
+ if (!needsReauth && record?.codexValidationPending) {
236
+ return { status: "warning", reason: "validation_pending" };
237
+ }
201
238
  const now = input.now ?? Date.now();
202
239
  const snap = getCodexAccountHealthSnapshot(input.accountId, now);
203
240
  return projectOAuthAccountHealth({
204
- needsReauth: input.needsReauth,
205
- reauthReason: input.needsReauth ? "refresh_failed" : undefined,
241
+ needsReauth,
242
+ reauthReason: needsReauth ? "refresh_failed" : undefined,
206
243
  cooldownUntilMs: snap?.cooldownUntil,
207
244
  cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
208
245
  now,
@@ -272,13 +309,11 @@ function collectLocalCodexEntries(now: number): OAuthHealthEntry[] {
272
309
  const hasPoolCredential = accountId !== MAIN_CODEX_ACCOUNT_ID && getCodexAccountCredential(accountId) !== null;
273
310
  if (!hasPoolCredential && !needsReauth && !snap) continue;
274
311
 
275
- const health = projectOAuthAccountHealth({
276
- needsReauth,
277
- reauthReason: needsReauth ? "refresh_failed" : undefined,
278
- cooldownUntilMs: snap?.cooldownUntil,
279
- cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
280
- now,
281
- });
312
+ // Call the projector rather than inlining a second copy of it. This collector serves the CLI
313
+ // (`ocx status`, `ocx doctor`) while the dashboard DTO goes through projectCodexAccountHealth,
314
+ // and the duplicated body is exactly how the CLI would have kept reporting a revoked account
315
+ // as healthy after the dashboard stopped.
316
+ const health = projectCodexAccountHealth({ accountId, needsReauth, now });
282
317
  pushEntry(entries, "codex", accountId, health);
283
318
  }
284
319
  return entries;
@@ -4,7 +4,7 @@ import { parseCallbackInput } from "./callback-server";
4
4
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
5
5
  import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
6
6
  import { resolveProviderApiKey } from "../providers/key-store";
7
- import { maskEmail } from "../lib/privacy";
7
+ import { projectEmail } from "../lib/privacy";
8
8
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
9
9
  import {
10
10
  OAuthMutationBusyError,
@@ -1781,19 +1781,54 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
1781
1781
  return { ok: true };
1782
1782
  }
1783
1783
 
1784
- export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
1784
+ export interface OAuthAccountSummary {
1785
+ id: string;
1786
+ alias?: string;
1787
+ email?: string;
1788
+ active: boolean;
1789
+ needsReauth?: boolean;
1790
+ expiresAt?: number;
1791
+ /**
1792
+ * Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
1793
+ * weighting a multi-account pool by seat size needs no per-provider branching (#3777).
1794
+ *
1795
+ * Always present and explicitly `null` when the tier is unknown. The distinction matters:
1796
+ * an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
1797
+ * version looked and upstream did not say. Omitting it would make those indistinguishable and
1798
+ * invite a consumer to assume a tier.
1799
+ *
1800
+ * Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
1801
+ * buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
1802
+ * and carries no subscription/tier field, and its token response carries none either. See
1803
+ * `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
1804
+ */
1805
+ plan: string | null;
1806
+ }
1785
1807
 
1786
- export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1808
+ /**
1809
+ * Token-safe login state for one provider.
1810
+ *
1811
+ * `maskEmails` is an explicit boolean rather than a config read (#3859). This module must not
1812
+ * acquire a dependency on config I/O to answer a redaction question: the caller already holds
1813
+ * the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
1814
+ * The default masks, so every existing caller keeps today's behaviour.
1815
+ */
1816
+ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1787
1817
  const cred = getCredential(provider);
1788
1818
  const st = loginState.get(provider);
1789
1819
  const set = getAccountSet(provider);
1790
1820
  const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
1791
1821
  id: a.id,
1792
1822
  ...(a.alias ? { alias: a.alias } : {}),
1793
- email: maskEmail(a.credential.email) ?? undefined,
1823
+ email: projectEmail(a.credential.email, maskEmails) ?? undefined,
1794
1824
  active: a.id === set.activeAccountId,
1795
1825
  ...(a.needsReauth ? { needsReauth: true } : {}),
1796
1826
  expiresAt: a.credential.expires,
1827
+ // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
1828
+ // exposes a subscription tier today, so there is nothing truthful to put here; deriving one
1829
+ // from quota percentages is not possible, because they are normalized per account and a
1830
+ // half-consumed small seat is indistinguishable from a half-consumed large one.
1831
+ plan: null,
1797
1832
  }));
1798
1833
 
1799
1834
  // A stored credential counts as "logged in" when it exists and is not marked for
@@ -1805,7 +1840,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1805
1840
  .find(a => a.id === set.activeAccountId)?.needsReauth === true;
1806
1841
  return {
1807
1842
  loggedIn: !!cred && !activeNeedsReauth,
1808
- email: maskEmail(cred?.email) ?? undefined,
1843
+ email: projectEmail(cred?.email, maskEmails) ?? undefined,
1809
1844
  source: cred?.source,
1810
1845
  error: st?.error,
1811
1846
  done: st?.done ?? false,
@@ -1813,10 +1848,13 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1813
1848
  };
1814
1849
  }
1815
1850
 
1816
- /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens, masked email). */
1817
- export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1851
+ /**
1852
+ * Token-safe per-provider login state for the CLI `ocx status` logins section. Never tokens; the
1853
+ * email follows the operator's `privacy.maskEmails` policy, masked by default (#3859).
1854
+ */
1855
+ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1818
1856
  return listOAuthProviders().map(provider => {
1819
- const status = getLoginStatus(provider);
1857
+ const status = getLoginStatus(provider, maskEmails);
1820
1858
  return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
1821
1859
  });
1822
1860
  }
@@ -211,21 +211,30 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
211
211
  if (!cred) continue;
212
212
  const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
213
213
  const needsWarmup = opts.codexWarmupEnabled
214
+ && !record.codexValidationPending
214
215
  && (record.lastCodexValidatedAt === undefined || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
215
216
  if (!needsRefresh && !needsWarmup) continue;
216
217
  const key = `codex:${id}`;
217
218
  if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
219
+ // The generation this sweep is acting on. A successful refresh commits a new one, and a
220
+ // failure that follows belongs to THAT credential, so the fence has to move with it.
221
+ let observedGeneration = record.generation;
218
222
  tasks.push(async () => {
223
+ let warmupGeneration: number | undefined;
219
224
  try {
220
225
  const token = await getValidCodexToken(id);
226
+ observedGeneration = token.generation;
221
227
  if (needsRefresh) result.refreshed.push(key);
222
- if (needsWarmup) {
228
+ const current = readCodexAccountRecord(id);
229
+ if (needsWarmup && current?.credential && current.deletedAt == null
230
+ && !current.codexValidationPending && current.generation === token.generation) {
231
+ warmupGeneration = token.generation;
223
232
  await warmCodexAccount({
224
233
  accessToken: token.accessToken,
225
234
  chatgptAccountId: token.chatgptAccountId,
226
235
  model: opts.codexWarmupModel,
227
236
  });
228
- markCodexAccountValidated(id, Date.now());
237
+ markCodexAccountValidated(id, Date.now(), token.generation);
229
238
  result.warmed.push(key);
230
239
  }
231
240
  backoff.delete(key);
@@ -235,11 +244,28 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
235
244
  result.skippedBackoff.push(key);
236
245
  return;
237
246
  }
238
- const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
239
- if (needsWarmup && !(err instanceof TokenRefreshError)) {
240
- markCodexAccountValidationFailed(id, codexWarmupFailureReason(err));
247
+ const terminal = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired")
248
+ ? err
249
+ : undefined;
250
+ if (terminal) {
251
+ // A revoked or expired refresh grant is the strongest terminal evidence there is, and
252
+ // it used to be the one class that never reached the record: the persisted-verdict
253
+ // branch below requires `needsWarmup`, which is false in the default configuration,
254
+ // and additionally excluded every TokenRefreshError. The verdict landed only in the
255
+ // in-memory backoff map, which no health surface reads and no restart survives, so the
256
+ // account kept its login-time "ok" while every request with it 401'd (#4120).
257
+ markCodexAccountValidationFailed(id, `refresh_${terminal.reason}`, {
258
+ expectedGeneration: observedGeneration,
259
+ terminal: true,
260
+ });
261
+ } else if (warmupGeneration !== undefined && !(err instanceof TokenRefreshError)) {
262
+ // warmupGeneration is set only once the warmup actually started against a record
263
+ // still at the token's generation, so it is a tighter fence than the pre-sweep read.
264
+ markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), {
265
+ expectedGeneration: warmupGeneration,
266
+ });
241
267
  }
242
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
268
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, terminal !== undefined, writerGeneration);
243
269
  result.failed.push(key);
244
270
  }
245
271
  });