@bitkyc08/opencodex 2.6.18 → 2.6.19

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DbTEyo46.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Barime1y.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DDcEW0Cm.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.18",
3
+ "version": "2.6.19",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -1,5 +1,9 @@
1
1
  import type { CursorRunRequest, CursorServerMessage } from "./types";
2
2
  import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
3
+ import { abortError, sleepWithAbort } from "../../upstream-retry";
4
+
5
+ // Compat: historical name for the shared abortable sleep, kept for external callers.
6
+ export { sleepWithAbort as abortAwareSleep } from "../../upstream-retry";
3
7
 
4
8
  export const CURSOR_RETRY_ATTEMPTS = 3;
5
9
  export const CURSOR_RETRY_BASE_MS = 250;
@@ -36,31 +40,6 @@ export function cursorRetryDelayMs(attempt: number): number {
36
40
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
37
41
  }
38
42
 
39
- function abortError(signal?: AbortSignal): unknown {
40
- return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
41
- }
42
-
43
- export async function abortAwareSleep(ms: number, signal?: AbortSignal): Promise<void> {
44
- if (ms <= 0) return;
45
- if (signal?.aborted) throw abortError(signal);
46
- await new Promise<void>((resolve, reject) => {
47
- let timer: ReturnType<typeof setTimeout>;
48
- const cleanup = () => {
49
- clearTimeout(timer);
50
- signal?.removeEventListener("abort", onAbort);
51
- };
52
- const onAbort = () => {
53
- cleanup();
54
- reject(abortError(signal));
55
- };
56
- timer = setTimeout(() => {
57
- cleanup();
58
- resolve();
59
- }, ms);
60
- signal?.addEventListener("abort", onAbort, { once: true });
61
- });
62
- }
63
-
64
43
  /**
65
44
  * A transport is safe to retry only if it explicitly reports the run request was never committed.
66
45
  * A transport without `requestCommitted` is treated as committed (not retryable) — fail safe.
@@ -105,7 +84,7 @@ export async function runCursorTurnWithRetry(
105
84
  requestUncommitted(transport) &&
106
85
  isRetryableCursorError(err);
107
86
  if (!canRetry) throw err;
108
- await abortAwareSleep(cursorRetryDelayMs(attempt), signal);
87
+ await sleepWithAbort(cursorRetryDelayMs(attempt), signal);
109
88
  } finally {
110
89
  await transport.close?.();
111
90
  }
@@ -1,5 +1,6 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
3
+ import { abortError, sleepWithAbort } from "../upstream-retry";
3
4
 
4
5
  const GOOGLE_RETRY_ATTEMPTS = 3;
5
6
  const GOOGLE_RETRY_BASE_MS = 250;
@@ -22,22 +23,6 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
22
23
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
23
24
  }
24
25
 
25
- function abortError(signal?: AbortSignal): unknown {
26
- return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
27
- }
28
-
29
- async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
30
- if (ms <= 0) return;
31
- if (signal?.aborted) throw abortError(signal);
32
- await new Promise<void>((resolve, reject) => {
33
- let timer: ReturnType<typeof setTimeout>;
34
- const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
35
- const onAbort = () => { cleanup(); reject(abortError(signal)); };
36
- timer = setTimeout(() => { cleanup(); resolve(); }, ms);
37
- signal?.addEventListener("abort", onAbort, { once: true });
38
- });
39
- }
40
-
41
26
  function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
42
27
  const timeout = AbortSignal.timeout(timeoutMs);
43
28
  return parent ? AbortSignal.any([parent, timeout]) : timeout;
@@ -1,5 +1,6 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
+ import { abortError, sleepWithAbort } from "../upstream-retry";
3
4
 
4
5
  const KIRO_RETRY_ATTEMPTS = 3;
5
6
  const KIRO_RETRY_BASE_MS = 250;
@@ -26,31 +27,6 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
26
27
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
27
28
  }
28
29
 
29
- function abortError(signal?: AbortSignal): unknown {
30
- return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
31
- }
32
-
33
- async function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
34
- if (ms <= 0) return;
35
- if (signal?.aborted) throw abortError(signal);
36
- await new Promise<void>((resolve, reject) => {
37
- let timer: ReturnType<typeof setTimeout>;
38
- const cleanup = () => {
39
- clearTimeout(timer);
40
- signal?.removeEventListener("abort", onAbort);
41
- };
42
- const onAbort = () => {
43
- cleanup();
44
- reject(abortError(signal));
45
- };
46
- timer = setTimeout(() => {
47
- cleanup();
48
- resolve();
49
- }, ms);
50
- signal?.addEventListener("abort", onAbort, { once: true });
51
- });
52
- }
53
-
54
30
  function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
55
31
  const timeout = AbortSignal.timeout(timeoutMs);
56
32
  return parent ? AbortSignal.any([parent, timeout]) : timeout;
package/src/cli.ts CHANGED
@@ -27,6 +27,7 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "./proxy-liveness";
27
27
  import { stopProxy } from "./process-control";
28
28
  import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
29
29
  import { drainAndShutdown, startServer } from "./server";
30
+ import { startTokenGuardian } from "./oauth/token-guardian";
30
31
  import { maybeShowStarPrompt } from "./star-prompt";
31
32
  import { maybeShowUpdatePrompt } from "./update-notify";
32
33
  import { syncModelsToCodex } from "./codex-sync";
@@ -134,10 +135,15 @@ async function handleStart(options: { block?: boolean } = {}) {
134
135
  writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
135
136
  writeJournal();
136
137
 
138
+ // Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd
139
+ // so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain.
140
+ const guardian = startTokenGuardian();
141
+
137
142
  let cleaned = false;
138
143
  const syncCleanup = () => {
139
144
  if (cleaned) return;
140
145
  cleaned = true;
146
+ try { guardian.stop(); } catch { /* best-effort */ }
141
147
  removePid(process.pid);
142
148
  removeRuntimePort(process.pid);
143
149
  if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
@@ -815,6 +815,30 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
815
815
  }
816
816
  }
817
817
 
818
+ /**
819
+ * Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
820
+ * `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
821
+ * only those ids. This is the single choke point applied at every CATALOG emission point (on-disk
822
+ * sync + /v1/models); the admin `/api/models` list stays unfiltered so the picker can show the full
823
+ * set. Live discovery is unaffected — this only decides what ships. See issue_052.
824
+ */
825
+ export function filterCatalogVisibleModels(
826
+ models: CatalogModel[],
827
+ config: Pick<OcxConfig, "disabledModels" | "providers">,
828
+ ): CatalogModel[] {
829
+ const disabled = new Set(config.disabledModels ?? []);
830
+ const allowByProvider = new Map<string, Set<string>>();
831
+ for (const [name, prov] of Object.entries(config.providers)) {
832
+ const sel = prov.selectedModels;
833
+ if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
834
+ }
835
+ return models.filter(m => {
836
+ if (disabled.has(`${m.provider}/${m.id}`)) return false;
837
+ const allow = allowByProvider.get(m.provider);
838
+ return !allow || allow.has(m.id);
839
+ });
840
+ }
841
+
818
842
  /**
819
843
  * Gather routed (non-forward) provider models across the config — the single source of truth for
820
844
  * the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
@@ -983,8 +1007,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
983
1007
 
984
1008
  // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
985
1009
  // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
986
- const disabled = new Set(config.disabledModels ?? []);
987
- const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
1010
+ const enabledGo = filterCatalogVisibleModels(goModels, config);
988
1011
  const featured = config.subagentModels ?? [];
989
1012
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
990
1013
  const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
package/src/doctor.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join, resolve } from "node:path";
13
- import { expandUserPath, getConfigDir, getConfigPath } from "./config";
13
+ import { expandUserPath, getConfigDir, getConfigPath, readConfigDiagnostics, readPid, resolveEnvValue } from "./config";
14
14
  import { readCodexTokens } from "./codex-auth-collision";
15
15
 
16
16
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -78,16 +78,143 @@ function readMounts(): string | null {
78
78
  const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
79
79
 
80
80
  export type ProxyEnvRow = { key: string; present: boolean };
81
+ export type EnvMap = Record<string, string | undefined>;
81
82
 
82
83
  /** Report only presence/absence of proxy env vars - never the value (it may
83
84
  * embed credentials). Checks both upper- and lower-case forms. */
84
- export function collectProxyEnv(): ProxyEnvRow[] {
85
+ export function collectProxyEnv(env: EnvMap = process.env): ProxyEnvRow[] {
85
86
  return PROXY_KEYS.map(key => ({
86
87
  key,
87
- present: !!(process.env[key]?.trim() || process.env[key.toLowerCase()]?.trim()),
88
+ present: !!(env[key]?.trim() || env[key.toLowerCase()]?.trim()),
88
89
  }));
89
90
  }
90
91
 
92
+ export type ConfiguredProxyDiagnostic = {
93
+ key: "config.proxy";
94
+ present: boolean;
95
+ configured: boolean;
96
+ source: "default" | "file" | "fallback";
97
+ detail: string;
98
+ };
99
+
100
+ function envReferenceName(value: string): string | null {
101
+ const braced = value.match(/^\$\{(\w+)\}$/);
102
+ if (braced) return braced[1]!;
103
+ const bare = value.match(/^\$(\w+)$/);
104
+ return bare ? bare[1]! : null;
105
+ }
106
+
107
+ export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
108
+ const diagnostics = readConfigDiagnostics();
109
+ const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
110
+ if (diagnostics.error) {
111
+ return {
112
+ key: "config.proxy",
113
+ present: false,
114
+ configured: false,
115
+ source: diagnostics.source,
116
+ detail: `config unreadable (${diagnostics.error})`,
117
+ };
118
+ }
119
+ if (!rawProxy) {
120
+ return {
121
+ key: "config.proxy",
122
+ present: false,
123
+ configured: false,
124
+ source: diagnostics.source,
125
+ detail: "not configured",
126
+ };
127
+ }
128
+
129
+ const envName = envReferenceName(rawProxy);
130
+ const resolved = resolveEnvValue(rawProxy);
131
+ if (resolved?.trim()) {
132
+ return {
133
+ key: "config.proxy",
134
+ present: true,
135
+ configured: true,
136
+ source: diagnostics.source,
137
+ detail: envName ? `env reference ${envName} resolved` : "value hidden",
138
+ };
139
+ }
140
+
141
+ return {
142
+ key: "config.proxy",
143
+ present: false,
144
+ configured: true,
145
+ source: diagnostics.source,
146
+ detail: envName ? `env reference ${envName} is unset` : "empty after resolution",
147
+ };
148
+ }
149
+
150
+ export function parseProcessEnvBlock(content: string): EnvMap {
151
+ const env: EnvMap = {};
152
+ for (const entry of content.split("\0")) {
153
+ if (!entry) continue;
154
+ const separator = entry.indexOf("=");
155
+ if (separator <= 0) continue;
156
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
157
+ }
158
+ return env;
159
+ }
160
+
161
+ export type RunningProxyEnvDiagnostic =
162
+ | { status: "not_running"; rows: ProxyEnvRow[] }
163
+ | { status: "ok"; pid: number; rows: ProxyEnvRow[] }
164
+ | { status: "unavailable"; pid: number; reason: string; rows: ProxyEnvRow[] };
165
+
166
+ type RunningProxyEnvDeps = {
167
+ readPidFn?: () => number | null;
168
+ readEnvironFn?: (pid: number) => string | null;
169
+ platform?: NodeJS.Platform | string;
170
+ };
171
+
172
+ function readProcessEnviron(pid: number): string | null {
173
+ try {
174
+ return readFileSync(`/proc/${pid}/environ`, "utf-8");
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /*
181
+ * [Decision Log]
182
+ * - Purpose: Make `ocx doctor` distinguish the current shell env from the already-running proxy process env.
183
+ * - Alternatives: Rename the old section only; parse service-manager env for each OS; read the recorded proxy PID's env presence.
184
+ * - Rationale: PID env presence is the narrowest useful diagnostic on Linux/WSL, avoids secret value output, and keeps unsupported platforms explicit.
185
+ */
186
+ export function collectRunningProxyEnv(deps: RunningProxyEnvDeps = {}): RunningProxyEnvDiagnostic {
187
+ const rowsWhenEmpty = () => collectProxyEnv({});
188
+ const pid = (deps.readPidFn ?? readPid)();
189
+ if (!pid) return { status: "not_running", rows: rowsWhenEmpty() };
190
+
191
+ const platform = deps.platform ?? process.platform;
192
+ if (platform !== "linux" && !deps.readEnvironFn) {
193
+ return {
194
+ status: "unavailable",
195
+ pid,
196
+ reason: "process env inspection is only supported on Linux",
197
+ rows: rowsWhenEmpty(),
198
+ };
199
+ }
200
+
201
+ const content = (deps.readEnvironFn ?? readProcessEnviron)(pid);
202
+ if (content === null) {
203
+ return {
204
+ status: "unavailable",
205
+ pid,
206
+ reason: "could not read process environment",
207
+ rows: rowsWhenEmpty(),
208
+ };
209
+ }
210
+
211
+ return {
212
+ status: "ok",
213
+ pid,
214
+ rows: collectProxyEnv(parseProcessEnvBlock(content)),
215
+ };
216
+ }
217
+
91
218
  export type WhamProbeResult = {
92
219
  ok: boolean;
93
220
  status: number | null;
@@ -142,11 +269,30 @@ export async function runDoctor(): Promise<void> {
142
269
  console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
143
270
  }
144
271
 
145
- console.log("\nProxy env (presence only)");
146
- for (const row of collectProxyEnv()) {
272
+ const currentProxyEnv = collectProxyEnv();
273
+ const configuredProxy = collectConfiguredProxy();
274
+ const runningProxyEnv = collectRunningProxyEnv();
275
+
276
+ console.log("\nCurrent doctor process proxy env (presence only)");
277
+ for (const row of currentProxyEnv) {
147
278
  console.log(` ${row.present ? "set " : "unset "} ${row.key}`);
148
279
  }
149
280
 
281
+ console.log("\nConfigured proxy (value hidden)");
282
+ console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
283
+
284
+ console.log("\nRunning proxy process proxy env (presence only)");
285
+ if (runningProxyEnv.status === "not_running") {
286
+ console.log(" -- no running ocx proxy process found");
287
+ } else if (runningProxyEnv.status === "unavailable") {
288
+ console.log(` -- pid ${runningProxyEnv.pid}: ${runningProxyEnv.reason}`);
289
+ } else {
290
+ console.log(` ok pid ${runningProxyEnv.pid}`);
291
+ for (const row of runningProxyEnv.rows) {
292
+ console.log(` ${row.present ? "set " : "unset "} ${row.key}`);
293
+ }
294
+ }
295
+
150
296
  console.log("\nWHAM reachability");
151
297
  const probe = await probeWham();
152
298
  const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
@@ -156,7 +302,7 @@ export async function runDoctor(): Promise<void> {
156
302
  // Hints, not fixes.
157
303
  const hints: string[] = [];
158
304
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
159
- const noProxy = collectProxyEnv().every(p => !p.present);
305
+ const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
160
306
  if (anyDrvfs) {
161
307
  hints.push("State dir is on a Windows-mounted (/mnt) drive. Prefer the Linux home (~) under WSL for token/lock reliability.");
162
308
  }
@@ -164,7 +310,7 @@ export async function runDoctor(): Promise<void> {
164
310
  if (probe.classification === "timeout" || probe.classification === "connect_error") {
165
311
  hints.push("WHAM probe could not reach chatgpt.com. On WSL2 this is often NAT/DNS/VPN. Quota cannot prime, so auto-switch stays on unknown scores.");
166
312
  if (noProxy) {
167
- hints.push("No *_PROXY env is set in this WSL process. If Windows uses a proxy/VPN, set HTTP(S)_PROXY here or enable WSL autoProxy so Bun fetch can reach the network.");
313
+ hints.push("No proxy is visible to this doctor process and config.proxy is unset or unresolved. If Windows uses a proxy/VPN, set config.proxy or start ocx from a shell with HTTP(S)_PROXY.");
168
314
  }
169
315
  }
170
316
  }
@@ -1,5 +1,5 @@
1
1
  import type { OAuthController, OAuthCredentials } from "./types";
2
- import type { OcxConfig, OcxProviderConfig } from "../types";
2
+ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
3
3
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
4
4
  import { maskEmail } from "../privacy";
5
5
  import { getCredential, saveCredential } from "./store";
@@ -24,6 +24,12 @@ interface OAuthProviderDef {
24
24
  /** provider entry written into config.json on first login. */
25
25
  providerConfig: OcxProviderConfig;
26
26
  defaultModel: string;
27
+ /**
28
+ * Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog
29
+ * 260703_oauth-multi-account-refresh-and-tos). A user's per-provider `config.providers[x].refreshPolicy`
30
+ * overrides this. Default when unset here: "lazy-only".
31
+ */
32
+ defaultRefreshPolicy?: RefreshPolicy;
27
33
  }
28
34
 
29
35
  function oauthConfig(id: string): OcxProviderConfig {
@@ -50,6 +56,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
50
56
  refresh: refreshAnthropicToken,
51
57
  providerConfig: oauthConfig("anthropic"),
52
58
  defaultModel: oauthDefaultModel("anthropic"),
59
+ // Anthropic actively server-side-blocks subscription OAuth outside its own clients (Feb 2026).
60
+ // Never generate background refresh traffic for it — grade 20, highest ToS risk.
61
+ defaultRefreshPolicy: "disabled",
53
62
  },
54
63
  kimi: {
55
64
  login: (ctrl) => loginKimi(ctrl),
@@ -87,6 +96,22 @@ export function isOAuthProvider(name: string): boolean {
87
96
  return name in OAUTH_PROVIDERS;
88
97
  }
89
98
 
99
+ function isRefreshPolicy(value: unknown): value is RefreshPolicy {
100
+ return value === "proactive" || value === "lazy-only" || value === "disabled";
101
+ }
102
+
103
+ /**
104
+ * The effective proactive-refresh policy for a provider: the user's per-provider
105
+ * `config.providers[provider].refreshPolicy` if set, else the provider def's risk-tiered default,
106
+ * else "lazy-only". The guardian acts only when this resolves to "proactive".
107
+ */
108
+ export function resolveRefreshPolicy(provider: string, config: OcxConfig): RefreshPolicy {
109
+ const override = config.providers[provider]?.refreshPolicy;
110
+ if (isRefreshPolicy(override)) return override;
111
+ const def = OAUTH_PROVIDERS[provider];
112
+ return def?.defaultRefreshPolicy ?? "lazy-only";
113
+ }
114
+
90
115
  /** The discovered project id stored on an OAuth credential (Antigravity CCA), if any. */
91
116
  export function getOAuthCredentialProjectId(provider: string): string | undefined {
92
117
  return getCredential(provider)?.projectId;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Token Guardian — background proactive OAuth refresh.
3
+ *
4
+ * Keeps idle tokens from aging out server-side (the reported multi-account Codex-pool bug) by
5
+ * refreshing them BEFORE a request needs them. It is a CALLER of the existing refresh machinery —
6
+ * it adds no new refresh/locking logic:
7
+ * - single-account providers → getValidAccessToken() (in-memory dedup + persist)
8
+ * - multi-account Codex pool → getValidCodexToken() (file lock + generation CAS + grant fingerprint)
9
+ *
10
+ * Safety by construction: the guardian only touches a provider whose EFFECTIVE refreshPolicy is
11
+ * "proactive", and the global switch (config.tokenGuardian.enabled) defaults OFF, so a default
12
+ * install adds zero ToS-detection surface. See devlog 260703_oauth-multi-account-refresh-and-tos.
13
+ */
14
+ import { loadConfig } from "../config";
15
+ import type { OcxConfig, OcxTokenGuardianConfig } from "../types";
16
+ import { getCredential } from "./store";
17
+ import { getValidAccessToken, listOAuthProviders, resolveRefreshPolicy } from "./index";
18
+ import {
19
+ getValidCodexToken,
20
+ listCodexAccountIds,
21
+ readCodexAccountRecord,
22
+ TokenRefreshError,
23
+ } from "../codex-account-store";
24
+
25
+ export interface TokenGuardianHandle {
26
+ stop(): void;
27
+ }
28
+
29
+ export interface GuardianSweepResult {
30
+ enabled: boolean;
31
+ refreshed: string[];
32
+ failed: string[];
33
+ skippedBackoff: string[];
34
+ }
35
+
36
+ const DEFAULTS = {
37
+ tickSeconds: 21600, // 6h — matches codex-lb's guardian cadence
38
+ jitterSeconds: 300,
39
+ concurrency: 3,
40
+ leadSeconds: 900,
41
+ failureBackoffBaseSeconds: 300,
42
+ failureBackoffMaxSeconds: 3600,
43
+ };
44
+
45
+ interface BackoffEntry {
46
+ attempts: number;
47
+ retryAfterMs: number;
48
+ }
49
+
50
+ // Module-scoped so backoff survives across sweeps within one process (keyed "oauth:<p>" / "codex:<id>").
51
+ const backoff = new Map<string, BackoffEntry>();
52
+
53
+ /** Test hook: clear backoff state between cases. */
54
+ export function __resetGuardianState(): void {
55
+ backoff.clear();
56
+ }
57
+
58
+ function num(value: number | undefined, fallback: number, min: number): number {
59
+ return typeof value === "number" && Number.isFinite(value) && value >= min ? value : fallback;
60
+ }
61
+
62
+ function resolved(g: OcxTokenGuardianConfig | undefined) {
63
+ return {
64
+ tickSeconds: num(g?.tickSeconds, DEFAULTS.tickSeconds, 60),
65
+ jitterSeconds: num(g?.jitterSeconds, DEFAULTS.jitterSeconds, 0),
66
+ concurrency: Math.max(1, Math.floor(num(g?.concurrency, DEFAULTS.concurrency, 1))),
67
+ leadSeconds: num(g?.leadSeconds, DEFAULTS.leadSeconds, 0),
68
+ backoffBaseSeconds: num(g?.failureBackoffBaseSeconds, DEFAULTS.failureBackoffBaseSeconds, 0),
69
+ backoffMaxSeconds: num(g?.failureBackoffMaxSeconds, DEFAULTS.failureBackoffMaxSeconds, 0),
70
+ };
71
+ }
72
+
73
+ function inBackoff(key: string, nowMs: number): boolean {
74
+ const entry = backoff.get(key);
75
+ return entry !== undefined && entry.retryAfterMs > nowMs;
76
+ }
77
+
78
+ function recordFailure(key: string, nowMs: number, baseSeconds: number, maxSeconds: number, permanent: boolean): void {
79
+ const prev = backoff.get(key);
80
+ const attempts = (prev?.attempts ?? 0) + 1;
81
+ // Permanent failures (revoked/expired refresh token) wait the full ceiling — nothing but a
82
+ // re-login fixes them, so there is no point retrying sooner.
83
+ const delaySeconds = permanent
84
+ ? maxSeconds
85
+ : Math.min(maxSeconds, baseSeconds * 2 ** (attempts - 1));
86
+ backoff.set(key, { attempts, retryAfterMs: nowMs + delaySeconds * 1000 });
87
+ }
88
+
89
+ async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
90
+ let cursor = 0;
91
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
92
+ while (cursor < tasks.length) {
93
+ const task = tasks[cursor++];
94
+ if (task) await task();
95
+ }
96
+ });
97
+ await Promise.all(workers);
98
+ }
99
+
100
+ /**
101
+ * One refresh sweep. Reads live config + stores; refreshes every proactive-policy credential that
102
+ * will expire before the next sweep (tick + lead horizon). Never throws — per-credential failures
103
+ * are captured into backoff and the result. Returns a redacted summary (provider names / account
104
+ * ids only, never tokens).
105
+ */
106
+ export async function guardianSweep(nowMs: number = Date.now()): Promise<GuardianSweepResult> {
107
+ const config: OcxConfig = loadConfig();
108
+ const g = config.tokenGuardian;
109
+ const result: GuardianSweepResult = { enabled: !!g?.enabled, refreshed: [], failed: [], skippedBackoff: [] };
110
+ if (!g?.enabled) return result;
111
+
112
+ const opts = resolved(g);
113
+ const horizonMs = (opts.tickSeconds + opts.leadSeconds) * 1000;
114
+ const tasks: Array<() => Promise<void>> = [];
115
+
116
+ // A) single-account OAuth providers
117
+ for (const provider of listOAuthProviders()) {
118
+ if (resolveRefreshPolicy(provider, config) !== "proactive") continue;
119
+ const cred = getCredential(provider);
120
+ if (!cred) continue;
121
+ if (cred.expires > nowMs + horizonMs) continue;
122
+ const key = `oauth:${provider}`;
123
+ if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
124
+ tasks.push(async () => {
125
+ try {
126
+ await getValidAccessToken(provider);
127
+ backoff.delete(key);
128
+ result.refreshed.push(key);
129
+ } catch {
130
+ // Single-account refresh throws generic errors; treat as transient (exponential backoff).
131
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false);
132
+ result.failed.push(key);
133
+ }
134
+ });
135
+ }
136
+
137
+ // B) multi-account Codex pool (gated on the chatgpt provider's policy)
138
+ if (resolveRefreshPolicy("chatgpt", config) === "proactive") {
139
+ for (const id of listCodexAccountIds()) {
140
+ const record = readCodexAccountRecord(id);
141
+ const cred = record?.deletedAt == null ? record?.credential : undefined;
142
+ if (!cred) continue;
143
+ if (cred.expiresAt > nowMs + horizonMs) continue;
144
+ const key = `codex:${id}`;
145
+ if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
146
+ tasks.push(async () => {
147
+ try {
148
+ await getValidCodexToken(id);
149
+ backoff.delete(key);
150
+ result.refreshed.push(key);
151
+ } catch (err) {
152
+ const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
153
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent);
154
+ result.failed.push(key);
155
+ }
156
+ });
157
+ }
158
+ }
159
+
160
+ await runWithConcurrency(tasks, opts.concurrency);
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Start the background sweep loop. Returns a handle whose stop() clears the pending timer (in-flight
166
+ * refreshes settle on their own). Schedules recursively so each interval gets fresh jitter. The loop
167
+ * runs even when the guardian is disabled (each sweep is a cheap no-op) so toggling `enabled` in
168
+ * config takes effect on the next tick without a restart.
169
+ */
170
+ export function startTokenGuardian(): TokenGuardianHandle {
171
+ let stopped = false;
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+
174
+ const scheduleNext = () => {
175
+ if (stopped) return;
176
+ const opts = resolved(loadConfig().tokenGuardian);
177
+ const delayMs = (opts.tickSeconds + Math.random() * opts.jitterSeconds) * 1000;
178
+ timer = setTimeout(runSweep, delayMs);
179
+ if (typeof timer.unref === "function") timer.unref(); // never keep the process alive for a sweep
180
+ };
181
+
182
+ const runSweep = () => {
183
+ void guardianSweep()
184
+ .then(r => {
185
+ if (r.enabled && (r.refreshed.length || r.failed.length)) {
186
+ console.log(`🛡️ token-guardian: refreshed ${r.refreshed.length}, failed ${r.failed.length}`);
187
+ }
188
+ })
189
+ .catch(err => console.log(`token-guardian sweep error: ${err instanceof Error ? err.message : String(err)}`))
190
+ .finally(scheduleNext);
191
+ };
192
+
193
+ scheduleNext();
194
+ return {
195
+ stop() {
196
+ stopped = true;
197
+ if (timer) clearTimeout(timer);
198
+ },
199
+ };
200
+ }