@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
package/src/lib/errors.ts CHANGED
@@ -4,6 +4,32 @@ export interface OcxErrorPayload {
4
4
  code: string | null;
5
5
  }
6
6
 
7
+ /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */
8
+ export const CYBER_POLICY_ERROR_CODE = "cyber_policy";
9
+
10
+ export function isCyberPolicyCode(code: string | null | undefined): boolean {
11
+ return code === CYBER_POLICY_ERROR_CODE;
12
+ }
13
+
14
+ /**
15
+ * Detect OpenAI cyber-policy refusals from message text when structured `code` was stripped.
16
+ * Matches Codex fallback copy and Cursor/API agent wording (session evidence 2026-07-24).
17
+ * Does not treat a bare `cyber_policy` token as conclusive — model ids / routing errors can
18
+ * include that substring without being a policy refusal.
19
+ */
20
+ export function isCyberPolicyMessage(text: string): boolean {
21
+ const lower = text.toLowerCase();
22
+ // Serialized error-code signatures only (not bare model-id collisions).
23
+ if (/"code"\s*:\s*"cyber_policy"/.test(lower)) return true;
24
+ if (/\bcode\s*[:=]\s*["']?cyber_policy\b/.test(lower)) return true;
25
+ if (lower.includes("high-risk cybersecurity")) return true;
26
+ if (lower.includes("high-risk cyber activity") || lower.includes("high-risk cyber ")) return true;
27
+ if (lower.includes("possible cybersecurity risk")) return true;
28
+ if (lower.includes("flagged") && lower.includes("cybersecurity")) return true;
29
+ if (lower.includes("flagged") && lower.includes("cyber activity")) return true;
30
+ return false;
31
+ }
32
+
7
33
  function isSubscriptionGateMessage(text: string): boolean {
8
34
  return (
9
35
  text.includes("requires a subscription") ||
@@ -90,6 +116,11 @@ export function classifyError(status: number, type: string, message: string): Oc
90
116
  ) {
91
117
  return { message, type: "invalid_request_error", code: "client_closed_request" };
92
118
  }
119
+ // Codex only shows the dedicated cyber UI when error.code === "cyber_policy".
120
+ // Prefer that code (and invalid_request_error) over generic remaps / 502 upstream_server_error.
121
+ if (type === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(text)) {
122
+ return { message, type: "invalid_request_error", code: CYBER_POLICY_ERROR_CODE };
123
+ }
93
124
  if (
94
125
  text.includes("context_length_exceeded") ||
95
126
  text.includes("context window") ||
@@ -190,6 +221,29 @@ export function classifyError(status: number, type: string, message: string): Oc
190
221
  return { message, type, code: type || null };
191
222
  }
192
223
 
224
+ /**
225
+ * True when a provider failure should participate in rate-limit / quota health blocking.
226
+ * Reuses {@link classifyError} so generic 429 wording and quota phrases stay aligned.
227
+ */
228
+ export function isRateLimitOrQuotaFailureMessage(message: string): boolean {
229
+ const normalized = String(message ?? "").trim();
230
+ if (!normalized) return false;
231
+ const numericStatus = Number(normalized);
232
+ if (numericStatus === 429 || numericStatus === 402) return true;
233
+ const statusHint = Number.isInteger(numericStatus) && numericStatus > 0 ? numericStatus : 0;
234
+ const classified = classifyError(statusHint, "", normalized);
235
+ if (
236
+ classified.type === "rate_limit_error"
237
+ || classified.code === "rate_limit_exceeded"
238
+ || classified.type === "insufficient_quota"
239
+ || classified.code === "insufficient_quota"
240
+ ) {
241
+ return true;
242
+ }
243
+ // Retained quota cue used by subagent health before classifyError covered it.
244
+ return normalized.toLowerCase().includes("usage limit");
245
+ }
246
+
193
247
  /** Best-effort parse of a retry delay embedded in an upstream error message. */
194
248
  export function parseRetryAfterFromMessage(message: string): number | undefined {
195
249
  const patterns = [
@@ -211,6 +265,8 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
211
265
  const lower = message.toLowerCase();
212
266
  // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs.
213
267
  if (isClientClosedMessage(lower)) return 499;
268
+ // Codex Transport maps cyber_policy only on HTTP 400 (SSE is code-based).
269
+ if (isCyberPolicyMessage(lower)) return 400;
214
270
  // See classifyError: this prefix now only means explicit request-size overflow (400);
215
271
  // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429.
216
272
  if (lower.includes("cursor resource limit exceeded")) return 400;
@@ -282,6 +338,9 @@ export function httpStatusFromTerminalError(error: {
282
338
  } | undefined): number {
283
339
  if (!error) return 502;
284
340
  if (error.code === "client_closed_request" || error.code === "client_cancelled") return 499;
341
+ if (isCyberPolicyCode(error.code) || (error.message ? isCyberPolicyMessage(error.message) : false)) {
342
+ return 400;
343
+ }
285
344
  if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
286
345
  if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
287
346
  if (
@@ -9,3 +9,12 @@ export function maskEmail(value: string | null | undefined): string | null {
9
9
  if (local.length === 2) return `${local[0]}*@${domain}`;
10
10
  return `${local[0]}***${local[local.length - 1]}@${domain}`;
11
11
  }
12
+
13
+ export function maskAccountId(value: string | null | undefined): string | null {
14
+ if (!value) return null;
15
+ const id = value.trim();
16
+ if (!id) return null;
17
+ // Short IDs would disclose the entire identifier via the suffix; use a non-identifying placeholder.
18
+ if (id.length <= 4) return "account-…";
19
+ return `account-…${id.slice(-4)}`;
20
+ }
@@ -45,15 +45,22 @@ export function gracefulStopHost(hostname: string | undefined): string {
45
45
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
46
46
  }
47
47
 
48
+ /**
49
+ * Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered
50
+ * that it must NOT be stopped from here, so callers must not escalate to a forced kill.
51
+ */
52
+ export type GracefulStopResult = boolean | "refused";
53
+
48
54
  /**
49
55
  * Ask a running proxy to stop itself via the management API (`POST /api/stop`), which
50
56
  * drains in-flight turns, restores native Codex, and cleans its pid/runtime files.
51
57
  * This is the only way to get a GRACEFUL stop on Windows, where the POSIX
52
58
  * SIGTERM-then-SIGKILL ladder does not exist and `taskkill /F` gives the proxy no
53
59
  * chance to run its shutdown handlers. Returns false when the proxy can't be reached
54
- * or doesn't exit in time — callers fall back to {@link killProxy}.
60
+ * or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"`
61
+ * when the proxy declines the stop (HTTP 409), which callers must NOT force past.
55
62
  */
56
- export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<boolean> {
63
+ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
57
64
  const readRuntime = io.readRuntime ?? readRuntimePort;
58
65
  const runtime = readRuntime(pid);
59
66
  if (!runtime?.port) return false;
@@ -67,8 +74,15 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
67
74
  const res = await fetchFn(`http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`, {
68
75
  method: "POST",
69
76
  headers,
70
- signal: AbortSignal.timeout(2000),
77
+ // Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them
78
+ // longer than a health poll so we prefer drain over taskkill /F.
79
+ signal: AbortSignal.timeout(io.exitTimeoutMs ? Math.min(io.exitTimeoutMs, 10_000) : 10_000),
71
80
  });
81
+ // 409 is the proxy REFUSING to stop (a service installed under another home owns it and
82
+ // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to
83
+ // SIGTERM here would run the daemon's cleanup and strip shared config out from under the
84
+ // still-running service. Report the refusal instead of forcing.
85
+ if (res.status === 409) return "refused";
72
86
  if (!res.ok) return false;
73
87
  } catch {
74
88
  return false;
@@ -91,13 +105,52 @@ function drainDeadlineMs(): number {
91
105
  /** Graceful-first stop: management-API drain, then the platform kill ladder. */
92
106
  export async function stopProxy(pid: number): Promise<void> {
93
107
  if (!isProcessAlive(pid)) return;
94
- if (await stopProxyGracefully(pid)) return;
108
+ const runtime = readRuntimePort(pid);
109
+ const graceful = await stopProxyGracefully(pid);
110
+ if (graceful === "refused") {
111
+ // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
112
+ // config while that service keeps the proxy alive.
113
+ throw new Error(
114
+ "The running proxy refused to stop: a service installed under a different "
115
+ + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
116
+ );
117
+ }
118
+ if (graceful) {
119
+ await waitForStoppedPort(runtime, pid);
120
+ return;
121
+ }
95
122
  killProxy(pid);
123
+ await waitForStoppedPort(runtime, pid);
124
+ }
125
+
126
+ /** After stop/kill, wait for the former listen port to become bindable (Windows drain). */
127
+ async function waitForStoppedPort(
128
+ runtime: { port: number; hostname?: string } | null | undefined,
129
+ stoppedPid?: number,
130
+ ): Promise<void> {
131
+ if (!runtime?.port) return;
132
+ try {
133
+ const { reclaimListenPort } = await import("../server/port-reclaim");
134
+ await reclaimListenPort(runtime.port, runtime.hostname ?? "127.0.0.1", {
135
+ timeoutMs: 15_000,
136
+ intervalMs: 100,
137
+ scanIntervalMs: 500,
138
+ // Only the process we just stopped — never kill a newly started twin proxy.
139
+ killOcxHolders: !!(stoppedPid && stoppedPid > 0),
140
+ onlyKillPids: stoppedPid && stoppedPid > 0 ? [stoppedPid] : [],
141
+ });
142
+ } catch {
143
+ /* best-effort — callers that need a hard guarantee reclaim again before bind */
144
+ }
96
145
  }
97
146
 
98
147
  export function killProxy(pid: number): void {
99
148
  if (!isProcessAlive(pid)) return;
100
149
  if (process.platform === "win32") {
150
+ // Windows process.kill(SIGTERM/SIGINT) is TerminateProcess — not a graceful signal.
151
+ // Graceful drain happens only via stopProxyGracefully() (POST /api/stop). This path
152
+ // is the hard fallback: taskkill /T /F so the process tree exits (ghost LISTEN /
153
+ // CLOSE_WAIT are then cleared by reclaimListenPort / SetTcpEntry).
101
154
  const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
102
155
  try {
103
156
  execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true });
@@ -0,0 +1,375 @@
1
+ import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing";
2
+ import { isAccountNeedsReauth } from "../codex/account-runtime-state";
3
+ import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
4
+ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
5
+ import { maskAccountId } from "../lib/privacy";
6
+ import { loadServiceTokenFromFile } from "../lib/service-secrets";
7
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
8
+ import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store";
9
+ import type { ProviderAccount } from "./types";
10
+
11
+ export type OAuthAccountHealth =
12
+ | { status: "healthy" }
13
+ | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
14
+ | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
15
+ | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" };
16
+
17
+ export type OAuthHealthLabel =
18
+ | "Healthy"
19
+ | "Rate limited"
20
+ | "Quota limited"
21
+ | "Reauthentication required"
22
+ | "Refresh failed"
23
+ | "Metadata mismatch"
24
+ | "Credential conflict";
25
+
26
+ /** Shared masked-id fallback when `maskAccountId` returns nullish. */
27
+ export const MASKED_ACCOUNT_FALLBACK = "account-…????";
28
+
29
+ export type OAuthHealthEntry = {
30
+ provider: string;
31
+ accountId: string;
32
+ health: OAuthAccountHealth;
33
+ action?: string;
34
+ };
35
+
36
+ export type OAuthAccountHealthFields = {
37
+ health: OAuthAccountHealth;
38
+ healthLabel: OAuthHealthLabel;
39
+ healthSummary: string;
40
+ healthAction?: string;
41
+ };
42
+
43
+ export type CollectOAuthHealthOptions = {
44
+ /** Skip chmod/backup side effects when reading credential files (doctor/status). */
45
+ observeOnly?: boolean;
46
+ /**
47
+ * When true (default), include Codex entries from this process's in-memory maps.
48
+ * CLI surfaces should prefer {@link collectOAuthHealthEntriesForCli} which queries the live proxy.
49
+ */
50
+ includeLocalCodex?: boolean;
51
+ };
52
+
53
+ type OAuthWarningReason = "refresh_conflict" | "metadata_mismatch" | "stale_credentials";
54
+
55
+ export function projectOAuthAccountHealth(input: {
56
+ needsReauth?: boolean;
57
+ reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
58
+ cooldownUntilMs?: number;
59
+ cooldownReason?: "rate_limit" | "quota";
60
+ warningReason?: OAuthWarningReason;
61
+ now?: number;
62
+ }): OAuthAccountHealth {
63
+ const now = input.now ?? Date.now();
64
+ if (input.needsReauth) {
65
+ return { status: "reauth_required", reason: input.reauthReason ?? "refresh_failed" };
66
+ }
67
+ if (
68
+ typeof input.cooldownUntilMs === "number"
69
+ && Number.isFinite(input.cooldownUntilMs)
70
+ && input.cooldownUntilMs > now
71
+ ) {
72
+ return {
73
+ status: "cooldown",
74
+ until: new Date(input.cooldownUntilMs).toISOString(),
75
+ reason: input.cooldownReason ?? "quota",
76
+ };
77
+ }
78
+ if (input.warningReason) {
79
+ return { status: "warning", reason: input.warningReason };
80
+ }
81
+ return { status: "healthy" };
82
+ }
83
+
84
+ /** Codex pool accounts are not a public `ocx login` provider; reauth is dashboard-driven. */
85
+ export const CODEX_REAUTH_ACTION = "reauthenticate via the dashboard Codex account pool";
86
+
87
+ function actionFor(provider: string, health: OAuthAccountHealth): string | undefined {
88
+ if (health.status === "reauth_required") {
89
+ if (provider === "codex") return CODEX_REAUTH_ACTION;
90
+ return `run \`ocx login ${provider}\``;
91
+ }
92
+ if (health.status === "cooldown") {
93
+ // Keep the transported deadline machine-readable (ISO); presentation layers localize/format.
94
+ return `wait until ${health.until} or start a new session with another eligible account`;
95
+ }
96
+ if (health.status === "warning" && health.reason === "refresh_conflict") {
97
+ return "re-run `ocx doctor` after ensuring only one proxy process writes the credential store";
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
103
+ switch (health.status) {
104
+ case "healthy":
105
+ return "Healthy";
106
+ case "cooldown":
107
+ return health.reason === "rate_limit" ? "Rate limited" : "Quota limited";
108
+ case "reauth_required":
109
+ return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
110
+ case "warning":
111
+ switch (health.reason) {
112
+ case "refresh_conflict":
113
+ return "Credential conflict";
114
+ case "metadata_mismatch":
115
+ return "Metadata mismatch";
116
+ case "stale_credentials":
117
+ return "Refresh failed";
118
+ }
119
+ }
120
+ }
121
+
122
+ function displayAccountForHealth(accountId: string): string {
123
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main account";
124
+ return maskAccountId(accountId) ?? MASKED_ACCOUNT_FALLBACK;
125
+ }
126
+
127
+ export function oauthHealthSummary(
128
+ provider: string,
129
+ accountId: string,
130
+ health: OAuthAccountHealth,
131
+ action?: string,
132
+ ): string {
133
+ const masked = displayAccountForHealth(accountId);
134
+ let summary: string;
135
+ switch (health.status) {
136
+ case "healthy":
137
+ summary = `${provider} ${masked}: healthy`;
138
+ break;
139
+ case "cooldown": {
140
+ const why = health.reason === "rate_limit" ? "rate limited" : "quota limited";
141
+ summary = `${provider} ${masked}: ${why} until ${health.until}. Routing for this account is paused until then.`;
142
+ break;
143
+ }
144
+ case "reauth_required":
145
+ summary = `${provider} ${masked}: reauthentication required (${health.reason.replaceAll("_", " ")}).`;
146
+ break;
147
+ case "warning":
148
+ summary = `${provider} ${masked}: ${health.reason.replaceAll("_", " ")}.`;
149
+ break;
150
+ }
151
+ if (action && health.status !== "healthy") {
152
+ return `${summary} Next: ${action}`;
153
+ }
154
+ return summary;
155
+ }
156
+
157
+ export function oauthAccountHealthFields(
158
+ provider: string,
159
+ accountId: string,
160
+ health: OAuthAccountHealth,
161
+ ): OAuthAccountHealthFields {
162
+ const action = actionFor(provider, health);
163
+ return {
164
+ health,
165
+ healthLabel: oauthHealthLabel(health),
166
+ healthSummary: oauthHealthSummary(provider, accountId, health, action),
167
+ ...(action ? { healthAction: action } : {}),
168
+ };
169
+ }
170
+
171
+ export function projectStoredOAuthAccountHealth(
172
+ provider: string,
173
+ account: ProviderAccount,
174
+ now = Date.now(),
175
+ opts: { observeOnly?: boolean } = {},
176
+ ): OAuthAccountHealth {
177
+ return projectOAuthAccountHealth({
178
+ needsReauth: account.needsReauth === true,
179
+ reauthReason: account.needsReauth === true ? "refresh_failed" : undefined,
180
+ warningReason: detectOAuthWarning(provider, account, opts.observeOnly === true, now),
181
+ now,
182
+ });
183
+ }
184
+
185
+ export function projectCodexAccountHealth(input: {
186
+ accountId: string;
187
+ needsReauth: boolean;
188
+ now?: number;
189
+ }): OAuthAccountHealth {
190
+ const now = input.now ?? Date.now();
191
+ const snap = getCodexAccountHealthSnapshot(input.accountId, now);
192
+ return projectOAuthAccountHealth({
193
+ needsReauth: input.needsReauth,
194
+ reauthReason: input.needsReauth ? "refresh_failed" : undefined,
195
+ cooldownUntilMs: snap?.cooldownUntil,
196
+ cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
197
+ now,
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Incomplete credentials warning. Kiro may intentionally store an empty refresh
203
+ * when authenticated via KIRO_ACCESS_TOKEN or a pasted access-only token, as long
204
+ * as the access token is still unexpired.
205
+ */
206
+ export function detectOAuthWarning(
207
+ provider: string,
208
+ account: ProviderAccount,
209
+ observeOnly = false,
210
+ now = Date.now(),
211
+ ): OAuthWarningReason | undefined {
212
+ const intent = observeOnly
213
+ ? peekOAuthRefreshIntent(provider, account.id)
214
+ : readOAuthRefreshIntent(provider, account.id);
215
+ if (intent?.uncertain) return "refresh_conflict";
216
+ const cred = account.credential;
217
+ if (!cred?.access) return "stale_credentials";
218
+ if (!cred.refresh) {
219
+ if (
220
+ provider === "kiro"
221
+ && typeof cred.expires === "number"
222
+ && Number.isFinite(cred.expires)
223
+ && cred.expires > now
224
+ ) {
225
+ return undefined;
226
+ }
227
+ return "stale_credentials";
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ function cooldownReasonFromSource(
233
+ source: CodexCooldownSource | undefined,
234
+ ): "rate_limit" | "quota" | undefined {
235
+ if (!source) return undefined;
236
+ return source === "retry-after" ? "rate_limit" : "quota";
237
+ }
238
+
239
+ function pushEntry(
240
+ entries: OAuthHealthEntry[],
241
+ provider: string,
242
+ accountId: string,
243
+ health: OAuthAccountHealth,
244
+ ): void {
245
+ const action = actionFor(provider, health);
246
+ entries.push({
247
+ provider,
248
+ accountId,
249
+ health,
250
+ ...(action ? { action } : {}),
251
+ });
252
+ }
253
+
254
+ function collectLocalCodexEntries(now: number): OAuthHealthEntry[] {
255
+ const entries: OAuthHealthEntry[] = [];
256
+ const codexIds = new Set(listCodexAccountIds());
257
+ codexIds.add(MAIN_CODEX_ACCOUNT_ID);
258
+ for (const accountId of codexIds) {
259
+ const snap = getCodexAccountHealthSnapshot(accountId, now);
260
+ const needsReauth = isAccountNeedsReauth(accountId);
261
+ const hasPoolCredential = accountId !== MAIN_CODEX_ACCOUNT_ID && getCodexAccountCredential(accountId) !== null;
262
+ if (!hasPoolCredential && !needsReauth && !snap) continue;
263
+
264
+ const health = projectOAuthAccountHealth({
265
+ needsReauth,
266
+ reauthReason: needsReauth ? "refresh_failed" : undefined,
267
+ cooldownUntilMs: snap?.cooldownUntil,
268
+ cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
269
+ now,
270
+ });
271
+ pushEntry(entries, "codex", accountId, health);
272
+ }
273
+ return entries;
274
+ }
275
+
276
+ export function collectOAuthHealthEntries(
277
+ now = Date.now(),
278
+ opts: CollectOAuthHealthOptions = {},
279
+ ): OAuthHealthEntry[] {
280
+ const observeOnly = opts.observeOnly === true;
281
+ const includeLocalCodex = opts.includeLocalCodex !== false;
282
+ const entries: OAuthHealthEntry[] = [];
283
+ const store = observeOnly ? peekAuthStore() : loadAuthStore();
284
+
285
+ for (const [provider, set] of Object.entries(store)) {
286
+ for (const account of set.accounts) {
287
+ const health = projectStoredOAuthAccountHealth(provider, account, now, { observeOnly });
288
+ pushEntry(entries, provider, account.id, health);
289
+ }
290
+ }
291
+
292
+ if (includeLocalCodex) {
293
+ for (const entry of collectLocalCodexEntries(now)) entries.push(entry);
294
+ }
295
+
296
+ return entries;
297
+ }
298
+
299
+ type ProxyCodexAccountHealth = {
300
+ id: string;
301
+ health?: OAuthAccountHealth;
302
+ needsReauth?: boolean;
303
+ };
304
+
305
+ const KNOWN_HEALTH_STATUSES = new Set(["healthy", "cooldown", "reauth_required", "warning"]);
306
+
307
+ function coerceRemoteAccountHealth(
308
+ account: ProxyCodexAccountHealth,
309
+ ): OAuthAccountHealth {
310
+ const raw = account.health;
311
+ if (raw && typeof raw === "object" && KNOWN_HEALTH_STATUSES.has((raw as { status?: string }).status ?? "")) {
312
+ return raw;
313
+ }
314
+ return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true });
315
+ }
316
+
317
+ async function fetchCodexHealthFromLiveProxy(
318
+ fetchImpl: typeof fetch = fetch,
319
+ findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
320
+ ): Promise<OAuthHealthEntry[] | null> {
321
+ const live = await findLiveProxyImpl();
322
+ if (!live) return null;
323
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
324
+ const headers: Record<string, string> = {};
325
+ if (token) headers.Authorization = `Bearer ${token}`;
326
+ try {
327
+ const res = await fetchImpl(
328
+ `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`,
329
+ { headers, signal: AbortSignal.timeout(4000) },
330
+ );
331
+ if (!res.ok) return null;
332
+ const json = await res.json() as { accounts?: ProxyCodexAccountHealth[] };
333
+ if (!Array.isArray(json.accounts)) return null;
334
+ const entries: OAuthHealthEntry[] = [];
335
+ for (const account of json.accounts) {
336
+ if (!account?.id || typeof account.id !== "string") continue;
337
+ pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
338
+ }
339
+ return entries;
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ /** How CLI/doctor obtained Codex cooldown/reauth (proxy memory only lives in the proxy). */
346
+ export type CodexHealthSource = "management-api" | "unavailable";
347
+
348
+ export type OAuthCliHealthReport = {
349
+ entries: OAuthHealthEntry[];
350
+ codexHealthSource: CodexHealthSource;
351
+ };
352
+
353
+ /** Shown by `ocx status` / `ocx doctor` when the proxy management API is unreachable. */
354
+ export const CODEX_HEALTH_UNAVAILABLE_NOTE =
355
+ "Codex health: unavailable (proxy not running; live cooldown/reauth requires the management API)";
356
+
357
+ /**
358
+ * CLI/doctor collector: observe-only OAuth store reads, and Codex health only from the
359
+ * running proxy management API. Never reads proxy process-local maps from the CLI process.
360
+ */
361
+ export async function collectOAuthHealthEntriesForCli(
362
+ now = Date.now(),
363
+ deps: {
364
+ fetchImpl?: typeof fetch;
365
+ findLiveProxyImpl?: typeof findLiveProxy;
366
+ } = {},
367
+ ): Promise<OAuthCliHealthReport> {
368
+ const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false });
369
+ const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl);
370
+ if (remote) {
371
+ for (const entry of remote) entries.push(entry);
372
+ return { entries, codexHealthSource: "management-api" };
373
+ }
374
+ return { entries, codexHealthSource: "unavailable" };
375
+ }