@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.
- package/README.md +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- 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 (
|
package/src/lib/privacy.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|